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.
Files changed (61) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +138 -134
  3. package/bin/odai-dsh-agent.mjs +108 -108
  4. package/package.json +69 -69
  5. package/preset/odai/agent.cordis.yml +260 -260
  6. package/preset/odai/preset.yml +3 -3
  7. package/preset/odai/runtime/compaction-config.mjs +388 -388
  8. package/preset/odai/runtime/context-activation.mjs +71 -0
  9. package/preset/odai/runtime/context-capability.mjs +86 -0
  10. package/preset/odai/runtime/governance.mjs +128 -121
  11. package/preset/odai/runtime/human-care.mjs +45 -0
  12. package/preset/odai/runtime/human-safety-continuity.mjs +2 -2
  13. package/preset/odai/runtime/human-safety.mjs +3 -3
  14. package/preset/odai/runtime/index.mjs +2461 -1977
  15. package/preset/odai/runtime/output-config.mjs +360 -304
  16. package/preset/odai/runtime/research-packet.mjs +150 -150
  17. package/preset/odai/runtime/responsibility-gap.mjs +8 -10
  18. package/preset/odai/runtime/responsibility-scope.mjs +199 -0
  19. package/preset/odai/runtime/role-overlays.mjs +27 -27
  20. package/preset/odai/runtime/route-card.mjs +168 -152
  21. package/preset/odai/runtime/router.mjs +683 -651
  22. package/preset/odai/runtime/routing-config.mjs +480 -436
  23. package/preset/odai/runtime/routing-context.mjs +321 -321
  24. package/preset/odai/runtime/semantic-memory-store.mjs +452 -452
  25. package/preset/odai/runtime/semantic-memory.mjs +842 -842
  26. package/preset/odai/runtime/session-compat.mjs +389 -389
  27. package/preset/odai/runtime/skill-bundle.mjs +318 -318
  28. package/preset/odai/runtime/skill-evolution.mjs +1413 -1413
  29. package/preset/odai/skills/odai/SKILL.md +69 -68
  30. package/preset/odai/skills/odai/agents/openai.yaml +4 -4
  31. package/preset/odai/skills/odai/assets/claude-agents/agent.md +11 -11
  32. package/preset/odai/skills/odai/assets/codex-agents/config.toml +11 -11
  33. package/preset/odai/skills/odai/assets/codex-agents/role.toml +5 -5
  34. package/preset/odai/skills/odai/assets/copilot-agents/agent.md +13 -13
  35. package/preset/odai/skills/odai/assets/hooks-policy.example.json +24 -24
  36. package/preset/odai/skills/odai/assets/routing-roles/controller.md +15 -15
  37. package/preset/odai/skills/odai/assets/routing-roles/executor.md +11 -11
  38. package/preset/odai/skills/odai/assets/routing-roles/frontend.md +7 -7
  39. package/preset/odai/skills/odai/assets/routing-roles/planner.md +29 -29
  40. package/preset/odai/skills/odai/assets/routing-roles/researcher.md +6 -6
  41. package/preset/odai/skills/odai/assets/routing-roles/reviewer.md +11 -11
  42. package/preset/odai/skills/odai/assets/task-state.md +14 -14
  43. package/preset/odai/skills/odai/manifest.json +37 -36
  44. package/preset/odai/skills/odai/references/care.md +26 -0
  45. package/preset/odai/skills/odai/references/craft.md +54 -54
  46. package/preset/odai/skills/odai/references/dao.md +30 -30
  47. package/preset/odai/skills/odai/references/human-safety.md +33 -54
  48. package/preset/odai/skills/odai/references/leverage.md +53 -53
  49. package/preset/odai/skills/odai/references/planning.md +82 -82
  50. package/preset/odai/skills/odai/references/support.md +36 -36
  51. package/preset/odai/skills/odai/references/verification.md +23 -23
  52. package/preset/odai/skills/odai/scripts/build-hooks.mjs +273 -273
  53. package/preset/odai/skills/odai/scripts/build-routing.mjs +274 -274
  54. package/preset/odai/skills/odai/scripts/install-routing.mjs +397 -397
  55. package/preset/odai/skills/odai/scripts/odai-hook.mjs +457 -457
  56. package/preset/odai/skills/odai/scripts/run-role.mjs +336 -336
  57. package/preset/odai/skills/odai/scripts/run-routing.mjs +1154 -1154
  58. package/preset/odai/skills/odai/scripts/verify-routing.mjs +103 -103
  59. package/scripts/verify-agent-load.mjs +257 -257
  60. package/src/dsh-version.mjs +6 -2
  61. 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 automatic responsibility and user-alignment gaps",
7
- "Users describe goals, constraints, materials, and acceptance; they never need to request internal roles, routing modes, or handoffs.",
8
- "Keep work direct when the current controller can close it reliably. Keywords such as plan, review, research, frontend, or independent are only clues and never sufficient routing authority.",
9
- "When project evidence reveals a real capability gap that can change the result, call odai_responsibility_gap with the smallest grounded proposal before the next affected action. The runtime chooses inline, same-turn, or child execution from the configured mapping, context need, evidence, and net benefit.",
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", description: "The unresolved capability or user-owned decision gap." },
62
- evidenceRefs: { type: "array", items: { type: "string" }, description: "Small stable references to current task evidence." },
63
- expectedChange: { type: "string", description: "The concrete decision, artifact, or acceptance result this responsibility can change." },
64
- question: { type: "string", description: "One concise question, required only for responsibility=user." },
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 current controller request route, not by starting a child. 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 the current controller turn because DSH routed children are read-only. Follow the claimed card exactly; do not reinterpret the request, broaden scope, or claim final acceptance. The card is consumed only after the effective executor request route is verified; route failure leaves it retryable.`,
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 may replace the current request route for a read-only same-turn check, but it must state that the result is not independent acceptance and must not edit, fix, or approve release.`,
14
- frontend: `## DSH frontend execution boundary
15
-
16
- This responsibility runs in the current controller turn so it retains the active conversation, workspace, dev-server, and browser context. It is not an independent child. DSH may use a user-persisted frontend provider/model/reasoning route and, only when that mapping explicitly includes maxTokens, apply that responsibility ceiling instead of the global controller ceiling for this routed turn. 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
- }
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
+ }