dsh-plugin-coding-delegate 0.0.0-stage → 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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +920 -2
  3. package/cordis.patch.example.yml +48 -0
  4. package/cordis.patch.yml +13 -0
  5. package/dist/attestation.d.ts +25 -0
  6. package/dist/attestation.js +44 -0
  7. package/dist/containment.d.ts +33 -0
  8. package/dist/containment.js +63 -0
  9. package/dist/context-quality.d.ts +79 -0
  10. package/dist/context-quality.js +181 -0
  11. package/dist/context.d.ts +39 -0
  12. package/dist/context.js +136 -0
  13. package/dist/contracts.d.ts +174 -0
  14. package/dist/contracts.js +415 -0
  15. package/dist/declaration-egress-hook.d.ts +45 -0
  16. package/dist/declaration-egress-hook.js +125 -0
  17. package/dist/declaration-egress.d.ts +37 -0
  18. package/dist/declaration-egress.js +169 -0
  19. package/dist/delegation.d.ts +339 -0
  20. package/dist/delegation.js +933 -0
  21. package/dist/emission.d.ts +70 -0
  22. package/dist/emission.js +310 -0
  23. package/dist/guard.d.ts +162 -0
  24. package/dist/guard.js +873 -0
  25. package/dist/host-events.d.ts +15 -0
  26. package/dist/host-events.js +27 -0
  27. package/dist/index.d.ts +560 -0
  28. package/dist/index.js +1398 -0
  29. package/dist/local-classifier.d.ts +52 -0
  30. package/dist/local-classifier.js +159 -0
  31. package/dist/logging.d.ts +21 -0
  32. package/dist/logging.js +90 -0
  33. package/dist/paths.d.ts +10 -0
  34. package/dist/paths.js +83 -0
  35. package/dist/profiles.d.ts +35 -0
  36. package/dist/profiles.js +95 -0
  37. package/dist/retry-context.d.ts +52 -0
  38. package/dist/retry-context.js +97 -0
  39. package/dist/roles.d.ts +129 -0
  40. package/dist/roles.js +363 -0
  41. package/dist/routing.d.ts +35 -0
  42. package/dist/routing.js +88 -0
  43. package/dist/savings-tracker.d.ts +128 -0
  44. package/dist/savings-tracker.js +340 -0
  45. package/dist/session-events.d.ts +17 -0
  46. package/dist/session-events.js +31 -0
  47. package/dist/verification.d.ts +104 -0
  48. package/dist/verification.js +560 -0
  49. package/package.json +66 -4
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ /**
3
+ * Recovery: a failed unit's failure locations become the next attempt's context.
4
+ *
5
+ * The ordinary cause of a unit that "failed for no visible reason" is that the worker was never shown the
6
+ * code it had to change. The architect declares `contextFiles` from its own reading; when that reading is
7
+ * too narrow the worker guesses, and the verdict comes back as a failure that already names the file.
8
+ *
9
+ * That location was being produced and thrown away. `RedactedFailure.location` carries `file:line` or
10
+ * `file:line:col`, `delegateWorker` returns it to the architect, and nothing used it.
11
+ *
12
+ * Recovering it is unusually cheap here, and that is the point: this plugin reads the file and puts it in
13
+ * the WORKER's prompt, while the architect receives metadata only. So the loop can widen the worker's
14
+ * view on a retry without widening the architect's window -- this plugin's own thesis, applied to its own
15
+ * failure path.
16
+ *
17
+ * Two properties the callers must keep, neither of which lives in this file:
18
+ *
19
+ * 1. **Best-effort.** Auto-injected context must never turn a delegation that would have run into a
20
+ * refusal. An automatic convenience that can fail a unit is worse than no convenience, so the caller
21
+ * keeps the declared injection unless the widened one resolves cleanly.
22
+ * 2. **Once.** The caller consumes the locations and clears them, so an old failure cannot silently
23
+ * influence every later unit in the session.
24
+ */
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.RETRY_CONTEXT_WINDOW_LINES = void 0;
27
+ exports.parseFailureLocations = parseFailureLocations;
28
+ exports.retryContextRequests = retryContextRequests;
29
+ /**
30
+ * The half-window, in lines, taken either side of a reported failure.
31
+ *
32
+ * A failure is a line, but the cause is usually a few lines up -- an import, a signature, a branch. Ten
33
+ * is enough to cover that and small enough that several files still fit the injection budget.
34
+ */
35
+ exports.RETRY_CONTEXT_WINDOW_LINES = 10;
36
+ // Greedy on the path so the LAST two numeric segments win. `C:\src\a.ts:12:5` has three colons in it, and
37
+ // reading the first would make the drive letter look like a line number.
38
+ const LOCATION_WITH_COLUMN = /^(.*):(\d+):(\d+)$/;
39
+ const LOCATION_WITHOUT_COLUMN = /^(.*):(\d+)$/;
40
+ /**
41
+ * The usable locations in a failure list. A failure without one is skipped rather than guessed at: a
42
+ * wrong path costs a wasted read, and a wrong line costs a misleading window.
43
+ */
44
+ function parseFailureLocations(failures) {
45
+ if (!Array.isArray(failures))
46
+ return [];
47
+ const locations = [];
48
+ for (const failure of failures) {
49
+ const location = typeof failure?.location === 'string' ? failure.location.trim() : '';
50
+ if (!location)
51
+ continue;
52
+ const withColumn = LOCATION_WITH_COLUMN.exec(location);
53
+ const match = withColumn ?? LOCATION_WITHOUT_COLUMN.exec(location);
54
+ if (!match)
55
+ continue;
56
+ const path = match[1].trim();
57
+ const line = Number(match[2]);
58
+ if (!path || !Number.isInteger(line) || line < 1)
59
+ continue;
60
+ locations.push({ path, line });
61
+ }
62
+ return locations;
63
+ }
64
+ /**
65
+ * One window per file, covering every failure reported in it.
66
+ *
67
+ * Per file rather than per failure, because injecting the same file twice would spend the byte budget
68
+ * twice for the same bytes. The window spans the earliest and latest reported lines, so two failures far
69
+ * apart in one file produce one request that covers both.
70
+ */
71
+ function retryContextRequests(locations, windowLines = exports.RETRY_CONTEXT_WINDOW_LINES) {
72
+ const window = Number.isInteger(windowLines) && windowLines >= 0 ? windowLines : exports.RETRY_CONTEXT_WINDOW_LINES;
73
+ const spans = new Map();
74
+ for (const location of locations ?? []) {
75
+ if (!location || typeof location.path !== 'string' || !location.path)
76
+ continue;
77
+ if (!Number.isInteger(location.line) || location.line < 1)
78
+ continue;
79
+ const prior = spans.get(location.path);
80
+ if (!prior)
81
+ spans.set(location.path, { min: location.line, max: location.line });
82
+ else
83
+ spans.set(location.path, {
84
+ min: Math.min(prior.min, location.line),
85
+ max: Math.max(prior.max, location.line),
86
+ });
87
+ }
88
+ const requests = [];
89
+ for (const [path, span] of spans) {
90
+ requests.push({
91
+ path,
92
+ startLine: Math.max(1, span.min - window),
93
+ endLine: span.max + window,
94
+ });
95
+ }
96
+ return requests;
97
+ }
@@ -0,0 +1,129 @@
1
+ export type SourceEgressPolicy = 'deny' | 'ask' | 'allow';
2
+ /** Blocks shorter than this are treated as quotations rather than as code being handed over. */
3
+ export declare const DEFAULT_SOURCE_EGRESS_MIN_LINES = 3;
4
+ export interface SourceEgressDetection {
5
+ found: boolean;
6
+ blocks: number;
7
+ languages: string[];
8
+ }
9
+ /**
10
+ * Find fenced source blocks in an outbound payload.
11
+ *
12
+ * The fence is built with `\x60` escapes rather than written literally: a literal triple backtick in
13
+ * this file makes the file unpatchable by a confined agent, because the emission scanner truncates a
14
+ * fenced body at the first backtick run inside it. The pattern is identical at runtime.
15
+ */
16
+ export declare function detectSourceEgress(text: string, options?: {
17
+ minLines?: number;
18
+ }): SourceEgressDetection;
19
+ /**
20
+ * Rule 8: source may not reach the cloud. A request bound for the local worker is not egress at all,
21
+ * so the policy never applies to it — which is the entire reason the lead tier runs locally.
22
+ */
23
+ export declare function evaluateSourceEgress(action: SourceEgressPolicy, detection: SourceEgressDetection, destination: 'cloud' | 'local'): {
24
+ kind: 'allow' | 'ask' | 'deny';
25
+ reason: string;
26
+ };
27
+ /** Bounded, newest-wins. Built from observed requests, because the host does not say which agent is which. */
28
+ export declare const AGENT_ROLE_LIMIT = 200;
29
+ /**
30
+ * Remember which role an agent last made a request as.
31
+ *
32
+ * This is a correlation, not lineage: the plugin sees an `agent` on `agent/request` and an `agent` on
33
+ * `tools/pre-execute`, and it assumes the same id means the same agent. That assumption is recorded
34
+ * rather than trusted — an unobserved id resolves to 'unknown' and the observation says so, so the
35
+ * record degrades honestly instead of inventing an attribution.
36
+ */
37
+ export declare function rememberAgentRole(agentId: string | undefined, role: 'architect' | 'lead'): void;
38
+ /**
39
+ * The role as the host's session lineage records it.
40
+ *
41
+ * `Session.header` carries `origin?: 'subagent'` and `delegationDepth?: number`, so the host says outright
42
+ * whether this is a root agent or one spawned beneath another. A root agent is the architect; anything the
43
+ * host has marked as spawned is not.
44
+ *
45
+ * A missing header returns 'unknown' rather than 'architect'. Absence of evidence is not evidence of
46
+ * rootness, and a plugin that guesses here is guessing about who may read source.
47
+ */
48
+ export declare function roleFromLineage(header: unknown): 'architect' | 'lead' | 'unknown';
49
+ /** The lineage role for a live agent, when the host hands one over. */
50
+ export declare function agentLineageRole(agent: unknown): 'architect' | 'lead' | 'unknown';
51
+ /**
52
+ * The role for an agent, preferring the host's lineage over the observed correlation.
53
+ *
54
+ * Lineage first, because it is what the host recorded. The correlation map below remains as the fallback
55
+ * for a host that says nothing -- which, before `Session.header` existed, was the only signal this plugin
56
+ * had, and the comment on `rememberAgentRole` said so honestly.
57
+ */
58
+ export declare function roleForAgent(agentId: string | undefined, agent?: {
59
+ session?: {
60
+ header?: unknown;
61
+ };
62
+ }): 'architect' | 'lead' | 'unknown';
63
+ /** The map is module state, so tests need a way to clear it. */
64
+ export declare function resetAgentRoles(): void;
65
+ export interface SourceReadObservation {
66
+ track: boolean;
67
+ role: 'architect' | 'lead' | 'unknown';
68
+ target?: string;
69
+ extension?: string;
70
+ reason: string;
71
+ }
72
+ /**
73
+ * Should this tool call be recorded as a source read?
74
+ *
75
+ * Observation, not enforcement. The architect is allowed to read source today — the guard gates only
76
+ * files a worker wrote — and that is not a claim this project wants to keep making on faith. Recording
77
+ * every source read is what will say whether the architect's access is ever used, and therefore whether
78
+ * it can be closed.
79
+ *
80
+ * The tool check matters as much as the path check: without it, the architect's own refused writes to
81
+ * source would be counted as reads, and the evidence this exists to gather would be wrong.
82
+ */
83
+ export declare function describeSourceRead(input: {
84
+ tool?: string;
85
+ target?: string;
86
+ role?: 'architect' | 'lead' | 'unknown';
87
+ }): SourceReadObservation;
88
+ /**
89
+ * Which providers are the lead tier? `leadTier` derives the list from the LEAD profile so the provider
90
+ * id is declared in one place; an explicit `leadProviders` list always wins.
91
+ *
92
+ * Takes a structural type rather than `PluginConfig`: importing that would be a cycle, because
93
+ * `index.ts` imports this module, and every field read here is optional anyway.
94
+ */
95
+ export declare function resolveLeadProviders(options?: {
96
+ leadProviders?: string[];
97
+ leadTier?: boolean;
98
+ }): string[];
99
+ export declare function resolveAgentRole(input: {
100
+ hostProvider?: string;
101
+ leadProviders?: string[];
102
+ }): {
103
+ role: 'architect' | 'lead';
104
+ reason: string;
105
+ };
106
+ /**
107
+ * The architect's request treatment: pin the provider, uncap the window, supply the tool and the role
108
+ * instruction. Extracted from the hook so the behaviour is testable without a host.
109
+ *
110
+ * Deliberately unchanged: the instruction is only injected into a `system` string or a `messages`
111
+ * array. A request carrying neither is left without it, because inventing a field the host may not
112
+ * read would be a silent no-op dressed up as a fix.
113
+ */
114
+ export declare function applyArchitectConfig(requestConfig: Record<string, any>, options?: {
115
+ cloudProvider?: string;
116
+ cloudModel?: string;
117
+ localProvider?: string;
118
+ localModel?: string;
119
+ rerouteLocal?: boolean;
120
+ architectInstruction?: string;
121
+ workerTool?: any;
122
+ }): Record<string, any>;
123
+ /**
124
+ * Apply the role. A lead request is returned unchanged: the plugin's job is to enforce boundaries, not
125
+ * to reinvent a preset it did not write.
126
+ */
127
+ export declare function applyAgentRole(requestConfig: Record<string, any>, role: {
128
+ role: 'architect' | 'lead';
129
+ }, architectOptions?: Parameters<typeof applyArchitectConfig>[1]): Record<string, any>;
package/dist/roles.js ADDED
@@ -0,0 +1,363 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.AGENT_ROLE_LIMIT = exports.DEFAULT_SOURCE_EGRESS_MIN_LINES = void 0;
37
+ exports.detectSourceEgress = detectSourceEgress;
38
+ exports.evaluateSourceEgress = evaluateSourceEgress;
39
+ exports.rememberAgentRole = rememberAgentRole;
40
+ exports.roleFromLineage = roleFromLineage;
41
+ exports.agentLineageRole = agentLineageRole;
42
+ exports.roleForAgent = roleForAgent;
43
+ exports.resetAgentRoles = resetAgentRoles;
44
+ exports.describeSourceRead = describeSourceRead;
45
+ exports.resolveLeadProviders = resolveLeadProviders;
46
+ exports.resolveAgentRole = resolveAgentRole;
47
+ exports.applyArchitectConfig = applyArchitectConfig;
48
+ exports.applyAgentRole = applyAgentRole;
49
+ const path = __importStar(require("path"));
50
+ const profiles_1 = require("./profiles");
51
+ const guard_1 = require("./guard");
52
+ const paths_1 = require("./paths");
53
+ /**
54
+ * Fenced-block languages that count as source. Scripts are included: a deployment script is source,
55
+ * and it is exactly the sort of thing that should not be typed into a metered cloud conversation.
56
+ */
57
+ const SOURCE_LANGUAGES = new Set([
58
+ 'ts', 'typescript', 'tsx', 'js', 'javascript', 'jsx', 'mjs', 'cjs',
59
+ 'py', 'python', 'rb', 'ruby', 'php', 'java', 'kt', 'kotlin', 'scala', 'swift', 'dart',
60
+ 'cs', 'csharp', 'fs', 'fsharp', 'vb', 'go', 'rs', 'rust',
61
+ 'c', 'h', 'cpp', 'c++', 'hpp', 'cc', 'mm',
62
+ 'ps1', 'powershell', 'sh', 'bash', 'zsh', 'fish', 'bat', 'cmd',
63
+ 'sql', 'html', 'css', 'scss', 'less', 'vue', 'svelte', 'lua', 'pl', 'perl', 'r',
64
+ 'ex', 'exs', 'erl', 'hs', 'clj', 'asm', 'sol',
65
+ ]);
66
+ /** Blocks shorter than this are treated as quotations rather than as code being handed over. */
67
+ exports.DEFAULT_SOURCE_EGRESS_MIN_LINES = 3;
68
+ /**
69
+ * Find fenced source blocks in an outbound payload.
70
+ *
71
+ * The fence is built with `\x60` escapes rather than written literally: a literal triple backtick in
72
+ * this file makes the file unpatchable by a confined agent, because the emission scanner truncates a
73
+ * fenced body at the first backtick run inside it. The pattern is identical at runtime.
74
+ */
75
+ function detectSourceEgress(text, options = {}) {
76
+ if (typeof text !== 'string' || !text)
77
+ return { found: false, blocks: 0, languages: [] };
78
+ const minLines = Math.max(1, Number(options.minLines ?? exports.DEFAULT_SOURCE_EGRESS_MIN_LINES));
79
+ const languages = [];
80
+ let blocks = 0;
81
+ const FENCE = '\x60\x60\x60';
82
+ const fenced = new RegExp(FENCE + '([a-zA-Z0-9_+#-]+)[ \\t]*\\n([\\s\\S]*?)' + FENCE, 'g');
83
+ let match;
84
+ while ((match = fenced.exec(text)) !== null) {
85
+ const language = match[1].toLowerCase();
86
+ if (!SOURCE_LANGUAGES.has(language))
87
+ continue;
88
+ const body = match[2].replace(/\n$/, '');
89
+ if (body.split('\n').length < minLines)
90
+ continue;
91
+ blocks += 1;
92
+ languages.push(language);
93
+ }
94
+ return { found: blocks > 0, blocks, languages };
95
+ }
96
+ /**
97
+ * Rule 8: source may not reach the cloud. A request bound for the local worker is not egress at all,
98
+ * so the policy never applies to it — which is the entire reason the lead tier runs locally.
99
+ */
100
+ function evaluateSourceEgress(action, detection, destination) {
101
+ if (destination === 'local') {
102
+ return {
103
+ kind: 'allow',
104
+ reason: 'the request is bound for the local worker, so nothing is leaving the machine',
105
+ };
106
+ }
107
+ if (!detection.found) {
108
+ return { kind: 'allow', reason: 'no fenced source block was found in the outbound payload' };
109
+ }
110
+ const summary = detection.blocks +
111
+ ' fenced source block(s) in the outbound payload (' +
112
+ detection.languages.join(', ') +
113
+ ')';
114
+ if (action === 'allow') {
115
+ return { kind: 'allow', reason: 'sourceEgress is allow, so ' + summary + ' will be transmitted' };
116
+ }
117
+ if (action === 'ask') {
118
+ return { kind: 'ask', reason: summary + ' needs an operator decision (sourceEgress is ask)' };
119
+ }
120
+ return {
121
+ kind: 'deny',
122
+ reason: 'rule 8 refuses this request: ' +
123
+ summary +
124
+ ". Set sourceEgress: 'ask' to approve case by case, or 'allow' to send source to the cloud " +
125
+ 'deliberately.',
126
+ };
127
+ }
128
+ /** Bounded, newest-wins. Built from observed requests, because the host does not say which agent is which. */
129
+ exports.AGENT_ROLE_LIMIT = 200;
130
+ const agentRoles = new Map();
131
+ /**
132
+ * Remember which role an agent last made a request as.
133
+ *
134
+ * This is a correlation, not lineage: the plugin sees an `agent` on `agent/request` and an `agent` on
135
+ * `tools/pre-execute`, and it assumes the same id means the same agent. That assumption is recorded
136
+ * rather than trusted — an unobserved id resolves to 'unknown' and the observation says so, so the
137
+ * record degrades honestly instead of inventing an attribution.
138
+ */
139
+ function rememberAgentRole(agentId, role) {
140
+ const id = String(agentId ?? '').trim();
141
+ if (!id)
142
+ return;
143
+ if (agentRoles.has(id))
144
+ agentRoles.delete(id);
145
+ agentRoles.set(id, role);
146
+ while (agentRoles.size > exports.AGENT_ROLE_LIMIT) {
147
+ const oldest = agentRoles.keys().next().value;
148
+ if (typeof oldest === 'string')
149
+ agentRoles.delete(oldest);
150
+ }
151
+ }
152
+ /**
153
+ * The role as the host's session lineage records it.
154
+ *
155
+ * `Session.header` carries `origin?: 'subagent'` and `delegationDepth?: number`, so the host says outright
156
+ * whether this is a root agent or one spawned beneath another. A root agent is the architect; anything the
157
+ * host has marked as spawned is not.
158
+ *
159
+ * A missing header returns 'unknown' rather than 'architect'. Absence of evidence is not evidence of
160
+ * rootness, and a plugin that guesses here is guessing about who may read source.
161
+ */
162
+ function roleFromLineage(header) {
163
+ if (!header || typeof header !== 'object')
164
+ return 'unknown';
165
+ const h = header;
166
+ if (h.origin === 'subagent')
167
+ return 'lead';
168
+ if (typeof h.delegationDepth === 'number' && h.delegationDepth > 0)
169
+ return 'lead';
170
+ return 'architect';
171
+ }
172
+ /** The lineage role for a live agent, when the host hands one over. */
173
+ function agentLineageRole(agent) {
174
+ const session = agent?.session;
175
+ return roleFromLineage(session?.header);
176
+ }
177
+ /**
178
+ * The role for an agent, preferring the host's lineage over the observed correlation.
179
+ *
180
+ * Lineage first, because it is what the host recorded. The correlation map below remains as the fallback
181
+ * for a host that says nothing -- which, before `Session.header` existed, was the only signal this plugin
182
+ * had, and the comment on `rememberAgentRole` said so honestly.
183
+ */
184
+ function roleForAgent(agentId, agent) {
185
+ const byLineage = agentLineageRole(agent);
186
+ if (byLineage !== 'unknown')
187
+ return byLineage;
188
+ const id = String(agentId ?? '').trim();
189
+ if (!id)
190
+ return 'unknown';
191
+ return agentRoles.get(id) ?? 'unknown';
192
+ }
193
+ /** The map is module state, so tests need a way to clear it. */
194
+ function resetAgentRoles() {
195
+ agentRoles.clear();
196
+ }
197
+ /**
198
+ * Should this tool call be recorded as a source read?
199
+ *
200
+ * Observation, not enforcement. The architect is allowed to read source today — the guard gates only
201
+ * files a worker wrote — and that is not a claim this project wants to keep making on faith. Recording
202
+ * every source read is what will say whether the architect's access is ever used, and therefore whether
203
+ * it can be closed.
204
+ *
205
+ * The tool check matters as much as the path check: without it, the architect's own refused writes to
206
+ * source would be counted as reads, and the evidence this exists to gather would be wrong.
207
+ */
208
+ function describeSourceRead(input) {
209
+ const role = input?.role ?? 'unknown';
210
+ const tool = String(input?.tool ?? '').trim().toLowerCase();
211
+ if (!guard_1.READ_TOOLS.has(tool)) {
212
+ return { track: false, role, reason: `'${tool || 'unknown tool'}' is not a read tool` };
213
+ }
214
+ const target = String(input?.target ?? '').trim();
215
+ if (!target)
216
+ return { track: false, role, reason: 'no target to attribute' };
217
+ const extension = path.extname(target).toLowerCase();
218
+ if (!paths_1.CODE_EXTENSIONS.has(extension)) {
219
+ return { track: false, role, target, extension, reason: 'not a source file' };
220
+ }
221
+ return {
222
+ track: true,
223
+ role,
224
+ target,
225
+ extension,
226
+ reason: `source read attributed to ${role}`,
227
+ };
228
+ }
229
+ /**
230
+ * Which providers are the lead tier? `leadTier` derives the list from the LEAD profile so the provider
231
+ * id is declared in one place; an explicit `leadProviders` list always wins.
232
+ *
233
+ * Takes a structural type rather than `PluginConfig`: importing that would be a cycle, because
234
+ * `index.ts` imports this module, and every field read here is optional anyway.
235
+ */
236
+ function resolveLeadProviders(options = {}) {
237
+ if (Array.isArray(options.leadProviders) && options.leadProviders.length > 0) {
238
+ return options.leadProviders;
239
+ }
240
+ return options.leadTier ? [profiles_1.PROFILES.LEAD.provider] : [];
241
+ }
242
+ function resolveAgentRole(input) {
243
+ const host = String(input.hostProvider ?? '')
244
+ .trim()
245
+ .toLowerCase();
246
+ const declared = (input.leadProviders ?? [])
247
+ .map((p) => String(p ?? '').trim().toLowerCase())
248
+ .filter(Boolean);
249
+ if (!host) {
250
+ return {
251
+ role: 'architect',
252
+ reason: 'the host resolved no provider, so the architect default applies',
253
+ };
254
+ }
255
+ if (declared.includes(host)) {
256
+ return {
257
+ role: 'lead',
258
+ reason: `provider '${host}' is declared as a non-architect (lead) provider`,
259
+ };
260
+ }
261
+ return { role: 'architect', reason: `provider '${host}' is not declared as a lead provider` };
262
+ }
263
+ /**
264
+ * The architect's request treatment: pin the provider, uncap the window, supply the tool and the role
265
+ * instruction. Extracted from the hook so the behaviour is testable without a host.
266
+ *
267
+ * Deliberately unchanged: the instruction is only injected into a `system` string or a `messages`
268
+ * array. A request carrying neither is left without it, because inventing a field the host may not
269
+ * read would be a silent no-op dressed up as a fix.
270
+ */
271
+ function applyArchitectConfig(requestConfig, options = {}) {
272
+ // A tripped DLP under dlpAction 'local' pins this request to the local provider instead of the cloud.
273
+ //
274
+ // The route is only rewritten when there is a route to rewrite it TO. Both options are operator config and
275
+ // both are optional, so the unconditional assignment that stood here wrote `provider: undefined` over
276
+ // whatever the host had selected -- and that is not a corner case, because the plugin's own entry in
277
+ // `config.json` carries no options at all. A plugin registered without a profile patch would have
278
+ // clobbered every request's provider. Leaving the host's route alone is what a session without this plugin
279
+ // does anyway, which is the honest fallback.
280
+ //
281
+ // One case deliberately still writes `undefined`: `rerouteLocal` means the operator asked for this request
282
+ // to go to the local worker, and with no local provider configured that cannot be satisfied locally.
283
+ // Falling back to the host's route could send source to the cloud on a request whose whole point was to
284
+ // keep it off the cloud, so the misconfiguration is left to fail loudly, and this says so.
285
+ const route = options.rerouteLocal
286
+ ? { provider: options.localProvider, model: options.localModel }
287
+ : { provider: options.cloudProvider, model: options.cloudModel };
288
+ const misrouted = typeof route.provider !== 'string' || route.provider.length === 0;
289
+ const mutatedConfig = { ...(requestConfig || {}) };
290
+ if (misrouted && options.rerouteLocal) {
291
+ console.warn('[LOCAL_GUARD] rerouteLocal is set but no localProvider is configured, so this request cannot be ' +
292
+ 'routed locally. It is left unrouted rather than sent to whatever route the host selected.');
293
+ mutatedConfig.provider = undefined;
294
+ mutatedConfig.model = options.localModel;
295
+ }
296
+ else if (!misrouted) {
297
+ mutatedConfig.provider = route.provider;
298
+ mutatedConfig.model = route.model;
299
+ }
300
+ // A request pinned to the local worker must not carry a reasoning effort.
301
+ //
302
+ // A session's model selection is sticky: the last `model/selection` record keeps
303
+ // `reasoningEffort: 'high'` on every later request, and a local model commonly advertises no efforts
304
+ // at all -- `qwen/qwen3.8-27b` under `lm-studio` is configured without any. The pin therefore handed
305
+ // the host a request it refused before any network I/O:
306
+ //
307
+ // provider "lm-studio" model "qwen/qwen3.8-27b" does not support reasoning effort "high"
308
+ //
309
+ // and because the effort is stored rather than per-message, it refused every later turn of that
310
+ // session too, which reads as a session that is permanently stuck. `index.ts` already clears this
311
+ // field when its own classifier selects the local worker; this is the other route into the local
312
+ // worker, so it clears the field as well. The cloud path keeps it: an effort is a capability of the
313
+ // model it was configured for, and stripping it there would silently downgrade the architect.
314
+ const pinnedLocal = options.rerouteLocal === true && !misrouted;
315
+ if (pinnedLocal)
316
+ delete mutatedConfig.reasoningEffort;
317
+ // Uncap the context window for the cloud architect.
318
+ delete mutatedConfig.contextWindow;
319
+ delete mutatedConfig.maxTokens;
320
+ delete mutatedConfig.max_tokens;
321
+ delete mutatedConfig.max_completion_tokens;
322
+ delete mutatedConfig.apiKey;
323
+ // Inject the `delegate_worker` tool definition for the architect thread.
324
+ if (Array.isArray(mutatedConfig.tools)) {
325
+ const hasWorker = mutatedConfig.tools.some((t) => (t?.function?.name || t?.name) === 'delegate_worker');
326
+ if (!hasWorker && options.workerTool)
327
+ mutatedConfig.tools.push(options.workerTool);
328
+ }
329
+ else if (options.workerTool) {
330
+ mutatedConfig.tools = [options.workerTool];
331
+ }
332
+ if (options.architectInstruction) {
333
+ if (typeof mutatedConfig.system === 'string') {
334
+ if (!mutatedConfig.system.includes('delegate_worker')) {
335
+ mutatedConfig.system += '\n\n' + options.architectInstruction;
336
+ }
337
+ }
338
+ else if (Array.isArray(mutatedConfig.messages)) {
339
+ const sysMsg = mutatedConfig.messages.find((m) => m.role === 'system');
340
+ if (sysMsg) {
341
+ if (typeof sysMsg.content === 'string' && !sysMsg.content.includes('delegate_worker')) {
342
+ sysMsg.content += '\n\n' + options.architectInstruction;
343
+ }
344
+ }
345
+ else {
346
+ mutatedConfig.messages.unshift({
347
+ role: 'system',
348
+ content: options.architectInstruction,
349
+ });
350
+ }
351
+ }
352
+ }
353
+ return mutatedConfig;
354
+ }
355
+ /**
356
+ * Apply the role. A lead request is returned unchanged: the plugin's job is to enforce boundaries, not
357
+ * to reinvent a preset it did not write.
358
+ */
359
+ function applyAgentRole(requestConfig, role, architectOptions = {}) {
360
+ if (role.role === 'lead')
361
+ return { ...(requestConfig || {}) };
362
+ return applyArchitectConfig(requestConfig, architectOptions);
363
+ }
@@ -0,0 +1,35 @@
1
+ export interface DestinationClassificationInput {
2
+ provider: string;
3
+ localProvider: string;
4
+ cloudProvider: string;
5
+ dlpTripped: boolean;
6
+ entropyOnly: boolean;
7
+ dlpAction?: 'block' | 'local' | 'warn' | string;
8
+ }
9
+ export interface DestinationClassificationResult {
10
+ destination: 'cloud' | 'local';
11
+ decision: 'proceed' | 'block';
12
+ rerouteLocal: boolean;
13
+ reason?: string;
14
+ }
15
+ /**
16
+ * Where an outbound request will actually land, and whether that is allowed.
17
+ *
18
+ * Both halves of this decision used to be made by comparing provider IDs, and both fail in the
19
+ * documented cloud-only setup where `localProvider` and `cloudProvider` are the same id:
20
+ *
21
+ * 1. An entropy-only DLP hit forced a local reroute even when `dlpAction` was 'block', so the
22
+ * payload was pinned to the cloud it was meant to be kept away from -- and the log said it was
23
+ * pinned to the LOCAL worker.
24
+ * 2. `destination` was then inferred from `provider === localProvider`, which is true in that
25
+ * setup, so source egress classified the route as local and ALLOWED source to the cloud.
26
+ *
27
+ * The plugin cannot see the architect's endpoint, so where it cannot tell it refuses rather than
28
+ * guessing. `dlpAction` is an explicit input for the same reason: a caller that pre-computes
29
+ * "should I reroute" cannot express that 'block' was overridden, which is how half 1 stayed hidden.
30
+ *
31
+ * `dlpAction` undefined preserves the previous behaviour deliberately: entropy-only hits reroute,
32
+ * high-confidence hits do not. Only an explicit 'block' changes that, and only 'local' authorises a
33
+ * reroute for a high-confidence hit.
34
+ */
35
+ export declare function classifyDestination(input: DestinationClassificationInput): DestinationClassificationResult;