xcoder-jarvis-agent 0.0.0-stage → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +243 -2
  3. package/dist/index.d.ts +27 -0
  4. package/dist/index.js +28 -0
  5. package/dist/jarvis/agent.d.ts +50 -0
  6. package/dist/jarvis/agent.js +305 -0
  7. package/dist/jarvis/argv.d.ts +30 -0
  8. package/dist/jarvis/argv.js +138 -0
  9. package/dist/jarvis/budget.d.ts +49 -0
  10. package/dist/jarvis/budget.js +83 -0
  11. package/dist/jarvis/chat/confirm.d.ts +23 -0
  12. package/dist/jarvis/chat/confirm.js +37 -0
  13. package/dist/jarvis/chat/lines.d.ts +41 -0
  14. package/dist/jarvis/chat/lines.js +93 -0
  15. package/dist/jarvis/chat/prompt.d.ts +10 -0
  16. package/dist/jarvis/chat/prompt.js +18 -0
  17. package/dist/jarvis/chat/session.d.ts +46 -0
  18. package/dist/jarvis/chat/session.js +121 -0
  19. package/dist/jarvis/chat/terminator.d.ts +14 -0
  20. package/dist/jarvis/chat/terminator.js +19 -0
  21. package/dist/jarvis/decision.d.ts +64 -0
  22. package/dist/jarvis/decision.js +116 -0
  23. package/dist/jarvis/exit.d.ts +17 -0
  24. package/dist/jarvis/exit.js +17 -0
  25. package/dist/jarvis/memory/store.d.ts +88 -0
  26. package/dist/jarvis/memory/store.js +189 -0
  27. package/dist/jarvis/prompt/assemble.d.ts +61 -0
  28. package/dist/jarvis/prompt/assemble.js +118 -0
  29. package/dist/jarvis/prompt/policy.d.ts +22 -0
  30. package/dist/jarvis/prompt/policy.js +80 -0
  31. package/dist/jarvis/prompt/skills.d.ts +69 -0
  32. package/dist/jarvis/prompt/skills.js +257 -0
  33. package/dist/jarvis/react-loop.d.ts +51 -0
  34. package/dist/jarvis/react-loop.js +160 -0
  35. package/dist/jarvis/telemetry/provenance.d.ts +54 -0
  36. package/dist/jarvis/telemetry/provenance.js +56 -0
  37. package/dist/jarvis/test-helpers.d.ts +14 -0
  38. package/dist/jarvis/test-helpers.js +69 -0
  39. package/dist/jarvis/tools/fs-tools.d.ts +28 -0
  40. package/dist/jarvis/tools/fs-tools.js +292 -0
  41. package/dist/jarvis/tools/memory-tools.d.ts +27 -0
  42. package/dist/jarvis/tools/memory-tools.js +83 -0
  43. package/dist/jarvis/tools/registry.d.ts +54 -0
  44. package/dist/jarvis/tools/registry.js +49 -0
  45. package/dist/jarvis/util/confirm.d.ts +28 -0
  46. package/dist/jarvis/util/confirm.js +54 -0
  47. package/dist/jarvis/util/kill.d.ts +10 -0
  48. package/dist/jarvis/util/kill.js +39 -0
  49. package/dist/jarvis/util/text.d.ts +74 -0
  50. package/dist/jarvis/util/text.js +190 -0
  51. package/dist/program.d.ts +2 -0
  52. package/dist/program.js +12 -0
  53. package/package.json +35 -3
@@ -0,0 +1,305 @@
1
+ // xcoder:version 16 | xcoder:task task-482677 | xcoder:updated 2026-11-05T03:51:32.763Z | xcoder:subtask code-st-729fd8
2
+ import fsp from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { createLlmClient, requestScope, shortId, XCODER_VERSION } from "xcoder-dna";
5
+ import { ArgvError, parseArgv, USAGE } from "./argv.js";
6
+ import { BudgetExceeded, clampIterations, DEFAULT_CAPS, RunBudget } from "./budget.js";
7
+ import { CHAT_DIRECTIVE } from "./chat/prompt.js";
8
+ import { runChatSession } from "./chat/session.js";
9
+ import { EXIT } from "./exit.js";
10
+ import { MemoryStore, resolveUserId, workspaceScopeId } from "./memory/store.js";
11
+ import { assembleSystemPrompt } from "./prompt/assemble.js";
12
+ import { POLICY_VERSION, renderPolicy } from "./prompt/policy.js";
13
+ import { discoverSkills } from "./prompt/skills.js";
14
+ import { MAX_ITERATIONS, runReactLoop } from "./react-loop.js";
15
+ import { writeProvenance, pruneProvenance } from "./telemetry/provenance.js";
16
+ import { createConfirm } from "./util/confirm.js";
17
+ import { sha256 } from "./util/text.js";
18
+ import { createKillProbe, readKillState } from "./util/kill.js";
19
+ import { DEFAULT_LIMITS, createToolRegistry } from "./tools/registry.js";
20
+ import { FS_TOOLS } from "./tools/fs-tools.js";
21
+ import { MEMORY_TOOLS } from "./tools/memory-tools.js";
22
+ export const JARVIS_AGENT_NAME = "jarvis-agent";
23
+ // Re-exported so `EXIT` keeps its documented public path (design §2.3, T7),
24
+ // even though the table itself now lives in a leaf module.
25
+ export { EXIT } from "./exit.js";
26
+ function emit(stream, text) {
27
+ try {
28
+ stream.write(text);
29
+ }
30
+ catch {
31
+ /* a closed stream must not fail the run */
32
+ }
33
+ }
34
+ /** Resolve a scope id from the real path, falling back to the raw path. */
35
+ async function scopeIdFor(workspaceRoot) {
36
+ try {
37
+ return workspaceScopeId(await fsp.realpath(workspaceRoot));
38
+ }
39
+ catch {
40
+ return workspaceScopeId(path.resolve(workspaceRoot));
41
+ }
42
+ }
43
+ /**
44
+ * Runs one Jarvis invocation and returns a process exit code.
45
+ *
46
+ * Never throws for expected conditions: a bad flag, a missing key, a model
47
+ * failure, a tripped kill switch and an exhausted budget all become a number
48
+ * plus a redacted message (T7). Provenance is written in a `finally` so an
49
+ * aborted run still leaves an audit record.
50
+ */
51
+ export async function runJarvisAgent(argv, deps = {}) {
52
+ const stdout = deps.stdout ?? process.stdout;
53
+ const stderr = deps.stderr ?? process.stderr;
54
+ const now = deps.now ?? (() => new Date());
55
+ let request;
56
+ try {
57
+ request = parseArgv(argv);
58
+ }
59
+ catch (err) {
60
+ const message = err instanceof ArgvError ? err.message : "invalid arguments";
61
+ emit(stderr, `jarvis: ${message}\n\n${USAGE}\n`);
62
+ return EXIT.error;
63
+ }
64
+ if (request.help) {
65
+ emit(stdout, `${USAGE}\n`);
66
+ return EXIT.answered;
67
+ }
68
+ // Chat runs many turns over one stdin/stdout pair; it owns its own loop.
69
+ // `runJarvisAgent` is injected rather than imported by the loop, so the two
70
+ // modules stay a DAG instead of a cycle (the loop calls back into this
71
+ // function once per turn, inheriting the same validation gate).
72
+ if (request.chat) {
73
+ return await runChatSession(request, runJarvisAgent, deps);
74
+ }
75
+ if (request.task.length === 0) {
76
+ emit(stderr, `jarvis: no task given\n\n${USAGE}\n`);
77
+ return EXIT.error;
78
+ }
79
+ const workspaceRoot = path.resolve(deps.workspaceRoot ?? process.cwd());
80
+ const memoryRoot = deps.memoryRoot ?? path.join(workspaceRoot, ".xcoder", "jarvis", "memory");
81
+ const runLogRoot = deps.runLogRoot ?? path.join(workspaceRoot, ".xcoder", "jarvis", "runs");
82
+ const env = deps.env ?? process.env;
83
+ const session = request.session ?? shortId("jarvis");
84
+ const runId = shortId("run");
85
+ const userId = resolveUserId(request.user);
86
+ const workspaceId = await scopeIdFor(workspaceRoot);
87
+ const kill = createKillProbe(workspaceRoot);
88
+ const confirm = deps.confirm ?? createConfirm({ stdin: process.stdin, stdout: stderr });
89
+ const memory = new MemoryStore({
90
+ rootDir: memoryRoot,
91
+ scope: { workspaceId, userId },
92
+ now,
93
+ });
94
+ const toolContext = {
95
+ workspaceRoot,
96
+ memory,
97
+ session,
98
+ confirm,
99
+ limits: DEFAULT_LIMITS,
100
+ };
101
+ const tools = deps.tools ?? [...FS_TOOLS, ...MEMORY_TOOLS];
102
+ const registry = createToolRegistry({ tools });
103
+ // Fail closed before doing any work at all.
104
+ const initialKill = readKillState(workspaceRoot);
105
+ if (initialKill.killed) {
106
+ const result = {
107
+ outcome: "aborted",
108
+ exitCode: EXIT.aborted,
109
+ iterations: 0,
110
+ runId,
111
+ session,
112
+ skills: [],
113
+ error: `kill switch tripped (${initialKill.reason})`,
114
+ };
115
+ emit(stderr, `jarvis: aborted — kill switch tripped (${initialKill.reason})\n`);
116
+ await safeWriteProvenance(runLogRoot, result, {
117
+ workspaceId,
118
+ userId,
119
+ policyVersion: POLICY_VERSION,
120
+ policySha256: sha256(renderPolicy(registry.describeForPrompt())),
121
+ skills: [],
122
+ skippedSkills: [],
123
+ toolAllowlist: registry.allowlist(),
124
+ toolsUsed: [],
125
+ memoryWrites: 0,
126
+ redactions: 0,
127
+ now,
128
+ });
129
+ return EXIT.aborted;
130
+ }
131
+ // ---- skills ----
132
+ const discovered = await discoverSkills({
133
+ workspaceRoot,
134
+ allow: request.skills.length > 0 ? request.skills : undefined,
135
+ env,
136
+ });
137
+ if (request.skills.length > 0 && discovered.packs.length === 0) {
138
+ emit(stderr, `jarvis: none of the requested skill packs were found (${request.skills.join(", ")})\n`);
139
+ return EXIT.error;
140
+ }
141
+ // In chat mode the user's line is recorded BEFORE the turn runs, so a failed
142
+ // turn still leaves a trace (tradeoff T4). It is stored as user-sourced data.
143
+ if (deps.chatTurn && request.memory) {
144
+ try {
145
+ await memory.append({
146
+ session,
147
+ kind: "task",
148
+ value: request.task,
149
+ source: "user",
150
+ });
151
+ }
152
+ catch {
153
+ /* a read-only memory dir must not fail the turn */
154
+ }
155
+ }
156
+ // ---- memory window (re-read every turn: this IS the continuity mechanism) ----
157
+ let window = [];
158
+ if (request.memory) {
159
+ try {
160
+ window = await memory.window();
161
+ }
162
+ catch {
163
+ window = [];
164
+ }
165
+ }
166
+ // ---- prompt ----
167
+ // The directive is the TRUSTED instruction section (PRM-02). A chat turn must
168
+ // never splice the user's text there, so it passes a constant; user text only
169
+ // ever arrives as transcript DATA (AC15).
170
+ const directive = deps.chatTurn ? CHAT_DIRECTIVE : request.task;
171
+ const assembled = assembleSystemPrompt({
172
+ agentName: JARVIS_AGENT_NAME,
173
+ directive,
174
+ skills: discovered.packs,
175
+ window,
176
+ toolAllowlist: registry.allowlist(),
177
+ toolManifest: registry.describeForPrompt(),
178
+ skippedSkills: discovered.skipped,
179
+ now: now(),
180
+ });
181
+ // ---- client ----
182
+ let client;
183
+ try {
184
+ client = deps.createClient ? deps.createClient() : createLlmClient("orchestrator");
185
+ }
186
+ catch (err) {
187
+ const message = err instanceof Error ? err.message.slice(0, 300) : "model client could not start";
188
+ emit(stderr, `jarvis: ${message}\n`);
189
+ return EXIT.error;
190
+ }
191
+ const budget = new RunBudget({ ...DEFAULT_CAPS, maxIterations: clampIterations(request.maxIterations, MAX_ITERATIONS) }, now);
192
+ const toolsUsed = [];
193
+ let outcome = "error";
194
+ let answer;
195
+ let iterations = 0;
196
+ let errorMessage;
197
+ let memoryWrites = 0;
198
+ try {
199
+ // Install a recordUsage sink so dna-side usage events reach provenance.
200
+ const loopResult = await requestScope.run({ recordUsage: () => undefined }, () => runReactLoop({
201
+ task: request.task,
202
+ systemPrompt: assembled,
203
+ client,
204
+ registry,
205
+ toolContext,
206
+ maxIterations: clampIterations(request.maxIterations, MAX_ITERATIONS),
207
+ budget,
208
+ isKilled: kill,
209
+ onToolUsed: (name, observation) => {
210
+ toolsUsed.push({ name, ok: observation.ok, bytes: observation.bytes });
211
+ if (name === "remember" && observation.ok)
212
+ memoryWrites += 1;
213
+ },
214
+ }));
215
+ outcome = loopResult.outcome;
216
+ answer = loopResult.answer;
217
+ iterations = loopResult.iterations;
218
+ errorMessage = loopResult.error;
219
+ }
220
+ catch (err) {
221
+ if (err instanceof BudgetExceeded) {
222
+ outcome = "budget_exceeded";
223
+ errorMessage = err.message;
224
+ }
225
+ else {
226
+ outcome = "error";
227
+ errorMessage = err instanceof Error ? err.message.slice(0, 300) : "run failed";
228
+ if (process.env["JARVIS_DEBUG"] === "1" && err instanceof Error && err.stack) {
229
+ emit(stderr, `${err.stack}\n`);
230
+ }
231
+ }
232
+ }
233
+ const exitCode = EXIT[outcome];
234
+ const result = {
235
+ outcome,
236
+ ...(answer !== undefined ? { answer } : {}),
237
+ exitCode,
238
+ iterations,
239
+ runId,
240
+ session,
241
+ skills: discovered.packs.map((p) => p.name),
242
+ ...(errorMessage !== undefined ? { error: errorMessage } : {}),
243
+ };
244
+ const usage = budget.snapshot();
245
+ const provenancePath = await safeWriteProvenance(runLogRoot, result, {
246
+ workspaceId,
247
+ userId,
248
+ policyVersion: assembled.provenance.policyVersion,
249
+ policySha256: assembled.provenance.policySha256,
250
+ skills: assembled.provenance.skills.map((s) => ({ name: s.name, sha256: s.sha256 })),
251
+ skippedSkills: assembled.provenance.skippedSkills,
252
+ toolAllowlist: registry.allowlist(),
253
+ toolsUsed,
254
+ memoryWrites,
255
+ redactions: memory.redactions(),
256
+ usage: { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens },
257
+ outcome,
258
+ iterations,
259
+ error: errorMessage,
260
+ now,
261
+ });
262
+ // Retention pruning is housekeeping: off the hot path, never fatal.
263
+ void pruneProvenance(runLogRoot, now()).catch(() => undefined);
264
+ // ---- report ----
265
+ if (request.json) {
266
+ emit(stdout, JSON.stringify({ ...result, provenancePath }) + "\n");
267
+ }
268
+ else if (outcome === "answered") {
269
+ emit(stdout, `${answer ?? ""}\n`);
270
+ }
271
+ else {
272
+ emit(stderr, `jarvis: ${outcome}${errorMessage ? ` — ${errorMessage}` : ""}\n`);
273
+ }
274
+ return exitCode;
275
+ }
276
+ async function safeWriteProvenance(dir, result, extra) {
277
+ try {
278
+ return await writeProvenance(dir, {
279
+ v: 1,
280
+ runId: result.runId,
281
+ ts: extra.now().toISOString(),
282
+ agent: JARVIS_AGENT_NAME,
283
+ version: XCODER_VERSION,
284
+ workspaceId: extra.workspaceId,
285
+ userId: extra.userId,
286
+ session: result.session,
287
+ policyVersion: extra.policyVersion,
288
+ policySha256: extra.policySha256,
289
+ skills: extra.skills,
290
+ skippedSkills: extra.skippedSkills,
291
+ toolAllowlist: extra.toolAllowlist,
292
+ toolsUsed: extra.toolsUsed,
293
+ iterations: extra.iterations ?? result.iterations,
294
+ outcome: extra.outcome ?? result.outcome,
295
+ usage: extra.usage ?? { inputTokens: 0, outputTokens: 0 },
296
+ memoryWrites: extra.memoryWrites,
297
+ redactions: extra.redactions,
298
+ ...(extra.error !== undefined ? { error: extra.error } : {}),
299
+ });
300
+ }
301
+ catch {
302
+ // A read-only workspace must not turn a good answer into a failure.
303
+ return undefined;
304
+ }
305
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * CLI argument parsing (design §2.3).
3
+ *
4
+ * argv is untrusted input (PRM-01). This parser therefore only ever produces
5
+ * a validated `JarvisRunRequest`: an unknown flag is a hard error rather than
6
+ * being ignored, and numeric values are clamped in one place so a hostile
7
+ * `--max-iterations 100000` cannot widen the loop bound.
8
+ */
9
+ export interface JarvisRunRequest {
10
+ task: string;
11
+ skills: string[];
12
+ maxIterations: number;
13
+ memory: boolean;
14
+ session?: string;
15
+ user?: string;
16
+ json: boolean;
17
+ help: boolean;
18
+ /** Interactive multi-turn mode (`--chat`). */
19
+ chat: boolean;
20
+ /** Session-wide cap on executed chat turns. Clamped to MAX_TURNS_CEILING. */
21
+ maxTurns: number;
22
+ }
23
+ export declare class ArgvError extends Error {
24
+ constructor(message: string);
25
+ }
26
+ export declare const USAGE: string;
27
+ /** Session turn cap (AI-09): a chat can never run unbounded. */
28
+ export declare const MAX_TURNS_CEILING = 200;
29
+ export declare const MAX_TURNS_DEFAULT = 50;
30
+ export declare function parseArgv(argv: readonly string[]): JarvisRunRequest;
@@ -0,0 +1,138 @@
1
+ // xcoder:version 6 | xcoder:task task-482677 | xcoder:updated 2026-11-05T03:46:34.204Z | xcoder:subtask code-st-729fd8
2
+ export class ArgvError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = "ArgvError";
6
+ }
7
+ }
8
+ export const USAGE = `
9
+ jarvis — a ReAct agent with memory and skill packs
10
+
11
+ Usage:
12
+ jarvis [options] <task words...>
13
+ jarvis --chat [options] multi-turn conversation on stdin
14
+
15
+ Options:
16
+ --skill NAME load only this skill pack (repeatable)
17
+ --max-iterations N reasoning steps, 1..8 (default 8)
18
+ --chat read many turns from stdin until exit|quit|bye or EOF
19
+ --max-turns N chat turns, 1..200 (default 50)
20
+ --no-memory do not load or write memory for this run
21
+ --session ID session label (default: generated)
22
+ --user ID memory scope label (default: OS user)
23
+ --json emit one JSON line per executed turn
24
+ --help show this help and exit 0
25
+
26
+ Chat:
27
+ Each line you type is one turn. Type exit, quit or bye on a line by itself
28
+ (case-insensitive) or send EOF to leave. A blank line is ignored. Earlier
29
+ turns are kept in the shared memory session, so the agent can recall them.
30
+ A terminator typed at a confirmation prompt denies that tool and ends the
31
+ session at the next turn boundary.
32
+
33
+ Exit codes:
34
+ 0 answered 1 error 2 iteration limit 3 budget exceeded 4 aborted
35
+ In chat mode 3 also means the --max-turns cap was reached.
36
+ `.trim();
37
+ const MAX_ITERATIONS_CEILING = 8;
38
+ /** Session turn cap (AI-09): a chat can never run unbounded. */
39
+ export const MAX_TURNS_CEILING = 200;
40
+ export const MAX_TURNS_DEFAULT = 50;
41
+ export function parseArgv(argv) {
42
+ const request = {
43
+ task: "",
44
+ skills: [],
45
+ maxIterations: MAX_ITERATIONS_CEILING,
46
+ memory: true,
47
+ session: undefined,
48
+ user: undefined,
49
+ json: false,
50
+ help: false,
51
+ chat: false,
52
+ maxTurns: MAX_TURNS_DEFAULT,
53
+ };
54
+ const positional = [];
55
+ for (let i = 0; i < argv.length; i += 1) {
56
+ const arg = argv[i];
57
+ if (arg === "--") {
58
+ positional.push(...argv.slice(i + 1));
59
+ break;
60
+ }
61
+ if (!arg.startsWith("--")) {
62
+ positional.push(arg);
63
+ continue;
64
+ }
65
+ switch (arg) {
66
+ case "--help":
67
+ request.help = true;
68
+ break;
69
+ case "--json":
70
+ request.json = true;
71
+ break;
72
+ case "--no-memory":
73
+ request.memory = false;
74
+ break;
75
+ case "--chat":
76
+ request.chat = true;
77
+ break;
78
+ case "--max-turns": {
79
+ const value = argv[i + 1];
80
+ if (!value || value.startsWith("--")) {
81
+ throw new ArgvError("--max-turns requires a number");
82
+ }
83
+ const n = Number(value);
84
+ if (!Number.isFinite(n) || !Number.isInteger(n)) {
85
+ throw new ArgvError("--max-turns must be an integer");
86
+ }
87
+ request.maxTurns = Math.min(MAX_TURNS_CEILING, Math.max(1, n));
88
+ i += 1;
89
+ break;
90
+ }
91
+ case "--skill": {
92
+ const value = argv[i + 1];
93
+ if (!value || value.startsWith("--"))
94
+ throw new ArgvError("--skill requires a pack name");
95
+ request.skills.push(value);
96
+ i += 1;
97
+ break;
98
+ }
99
+ case "--max-iterations": {
100
+ const value = argv[i + 1];
101
+ if (!value || value.startsWith("--")) {
102
+ throw new ArgvError("--max-iterations requires a number");
103
+ }
104
+ const n = Number(value);
105
+ if (!Number.isFinite(n) || !Number.isInteger(n)) {
106
+ throw new ArgvError("--max-iterations must be an integer");
107
+ }
108
+ request.maxIterations = Math.min(MAX_ITERATIONS_CEILING, Math.max(1, n));
109
+ i += 1;
110
+ break;
111
+ }
112
+ case "--session":
113
+ case "--user": {
114
+ const value = argv[i + 1];
115
+ if (!value || value.startsWith("--"))
116
+ throw new ArgvError(`${arg} requires a value`);
117
+ if (arg === "--session")
118
+ request.session = value.slice(0, 128);
119
+ else
120
+ request.user = value.slice(0, 128);
121
+ i += 1;
122
+ break;
123
+ }
124
+ default:
125
+ // An unrecognised flag is an error, not something to ignore: silently
126
+ // accepting it could hide a misspelled security-relevant option.
127
+ throw new ArgvError(`unknown option ${arg}`);
128
+ }
129
+ }
130
+ request.task = positional.join(" ").trim();
131
+ // Tradeoff T2: a typed task alongside --chat is an error, never silently
132
+ // discarded — a dropped task looks like a model bug to the person who
133
+ // typed it. The chat loop takes its turns from stdin only.
134
+ if (request.chat && request.task.length > 0) {
135
+ throw new ArgvError("--chat reads its turns from stdin; do not pass a task as well");
136
+ }
137
+ return request;
138
+ }
@@ -0,0 +1,49 @@
1
+ export interface BudgetCaps {
2
+ /** Hard iteration ceiling. Clamped to 1..MAX_ITERATIONS by the runner. */
3
+ maxIterations: number;
4
+ maxInputTokens: number;
5
+ maxOutputTokens: number;
6
+ maxWallMs: number;
7
+ }
8
+ export declare const DEFAULT_CAPS: BudgetCaps;
9
+ export declare class BudgetExceeded extends Error {
10
+ readonly dimension: "iterations" | "input-tokens" | "output-tokens" | "wall-clock";
11
+ constructor(message: string, dimension: "iterations" | "input-tokens" | "output-tokens" | "wall-clock");
12
+ }
13
+ export interface BudgetSnapshot {
14
+ inputTokens: number;
15
+ outputTokens: number;
16
+ wallMs: number;
17
+ iterations: number;
18
+ }
19
+ /**
20
+ * Per-run resource cap (AI-09).
21
+ *
22
+ * Jarvis owns this rather than reusing xcoder-dna's `RequestBudget` because
23
+ * that class is not exported from the dna barrel (and reaching past the
24
+ * barrel is forbidden by the boundary checker), and because a dollar cap is
25
+ * meaningless when the caller may be pointed at a local model. Caps here are
26
+ * token, iteration and wall-clock based. See design tradeoff T4.
27
+ */
28
+ export declare class RunBudget {
29
+ private readonly caps;
30
+ private readonly now;
31
+ private inputTokens;
32
+ private outputTokens;
33
+ private iterations;
34
+ private readonly startedAt;
35
+ constructor(caps?: BudgetCaps, now?: () => Date);
36
+ /** Count one completed reasoning iteration. */
37
+ countIteration(): void;
38
+ chargeUsage(u: {
39
+ inputTokens: number;
40
+ outputTokens: number;
41
+ }): void;
42
+ /** Throws BudgetExceeded as soon as any dimension is over its cap. */
43
+ check(): void;
44
+ remainingIterations(): number;
45
+ wallMs(): number;
46
+ snapshot(): BudgetSnapshot;
47
+ }
48
+ /** Clamp a user-supplied iteration count into the safe band. */
49
+ export declare function clampIterations(requested: number | undefined, fallback?: number): number;
@@ -0,0 +1,83 @@
1
+ // xcoder:version 1 | xcoder:task task-f1b5f6 | xcoder:updated 2026-11-05T02:44:59.650Z | xcoder:subtask code-st-53c7b5
2
+ export const DEFAULT_CAPS = {
3
+ maxIterations: 8,
4
+ maxInputTokens: 60_000,
5
+ maxOutputTokens: 16_000,
6
+ maxWallMs: 180_000,
7
+ };
8
+ export class BudgetExceeded extends Error {
9
+ dimension;
10
+ constructor(message, dimension) {
11
+ super(message);
12
+ this.dimension = dimension;
13
+ this.name = "BudgetExceeded";
14
+ }
15
+ }
16
+ /**
17
+ * Per-run resource cap (AI-09).
18
+ *
19
+ * Jarvis owns this rather than reusing xcoder-dna's `RequestBudget` because
20
+ * that class is not exported from the dna barrel (and reaching past the
21
+ * barrel is forbidden by the boundary checker), and because a dollar cap is
22
+ * meaningless when the caller may be pointed at a local model. Caps here are
23
+ * token, iteration and wall-clock based. See design tradeoff T4.
24
+ */
25
+ export class RunBudget {
26
+ caps;
27
+ now;
28
+ inputTokens = 0;
29
+ outputTokens = 0;
30
+ iterations = 0;
31
+ startedAt;
32
+ constructor(caps = DEFAULT_CAPS, now = () => new Date()) {
33
+ this.caps = caps;
34
+ this.now = now;
35
+ this.startedAt = this.now().getTime();
36
+ }
37
+ /** Count one completed reasoning iteration. */
38
+ countIteration() {
39
+ this.iterations += 1;
40
+ this.check();
41
+ }
42
+ chargeUsage(u) {
43
+ const input = Number.isFinite(u.inputTokens) ? Math.max(0, u.inputTokens) : 0;
44
+ const output = Number.isFinite(u.outputTokens) ? Math.max(0, u.outputTokens) : 0;
45
+ this.inputTokens += input;
46
+ this.outputTokens += output;
47
+ this.check();
48
+ }
49
+ /** Throws BudgetExceeded as soon as any dimension is over its cap. */
50
+ check() {
51
+ if (this.iterations > this.caps.maxIterations) {
52
+ throw new BudgetExceeded(`iteration budget exhausted (${this.caps.maxIterations})`, "iterations");
53
+ }
54
+ if (this.inputTokens > this.caps.maxInputTokens) {
55
+ throw new BudgetExceeded(`input token budget exhausted (${this.caps.maxInputTokens})`, "input-tokens");
56
+ }
57
+ if (this.outputTokens > this.caps.maxOutputTokens) {
58
+ throw new BudgetExceeded(`output token budget exhausted (${this.caps.maxOutputTokens})`, "output-tokens");
59
+ }
60
+ if (this.wallMs() > this.caps.maxWallMs) {
61
+ throw new BudgetExceeded(`wall-clock budget exhausted (${this.caps.maxWallMs}ms)`, "wall-clock");
62
+ }
63
+ }
64
+ remainingIterations() {
65
+ return Math.max(0, this.caps.maxIterations - this.iterations);
66
+ }
67
+ wallMs() {
68
+ return Math.max(0, this.now().getTime() - this.startedAt);
69
+ }
70
+ snapshot() {
71
+ return {
72
+ inputTokens: this.inputTokens,
73
+ outputTokens: this.outputTokens,
74
+ wallMs: this.wallMs(),
75
+ iterations: this.iterations,
76
+ };
77
+ }
78
+ }
79
+ /** Clamp a user-supplied iteration count into the safe band. */
80
+ export function clampIterations(requested, fallback = 8) {
81
+ const base = Number.isFinite(requested) ? Math.trunc(requested) : fallback;
82
+ return Math.min(8, Math.max(1, base));
83
+ }
@@ -0,0 +1,23 @@
1
+ import type { ConfirmFn } from "../util/confirm.js";
2
+ import type { LineSource } from "./lines.js";
3
+ /**
4
+ * Human-in-the-loop gate for chat mode (AI-03, design K3/AC20).
5
+ *
6
+ * Three properties matter:
7
+ * - it reads from the SAME LineSource the loop uses, so there is still only
8
+ * one reader on stdin and a prompt cannot swallow a queued turn;
9
+ * - it fails closed when the session is not interactive — nobody is there to
10
+ * approve, so the answer is deny;
11
+ * - a terminator typed at the prompt denies the tool and records a pending
12
+ * exit, so a person can leave a session that is asking them something
13
+ * (tradeoff T5). Re-queueing the line would risk an unbounded read cycle.
14
+ */
15
+ export interface ChatConfirm extends ConfirmFn {
16
+ /** True once a terminator was typed at a prompt; the loop exits at the next boundary. */
17
+ readonly pendingExit: boolean;
18
+ }
19
+ export declare function createChatConfirm(opts: {
20
+ lines: LineSource;
21
+ stdout: NodeJS.WritableStream;
22
+ isInteractive: boolean;
23
+ }): ChatConfirm;
@@ -0,0 +1,37 @@
1
+ import { isTerminator } from "./terminator.js";
2
+ const RISK_LABEL = {
3
+ read: "reads data",
4
+ write: "modifies local state",
5
+ irreversible: "CANNOT BE UNDONE",
6
+ };
7
+ const YES_RE = /^y(es)?$/i;
8
+ export function createChatConfirm(opts) {
9
+ const { lines, stdout, isInteractive } = opts;
10
+ let pendingExit = false;
11
+ const confirm = async (req) => {
12
+ if (!isInteractive)
13
+ return false;
14
+ const summary = req.summary.slice(0, 200).replace(/\s+/g, " ");
15
+ try {
16
+ stdout.write(`jarvis: allow tool "${req.tool}" (${RISK_LABEL[req.risk]})? ${summary} [y/N] `);
17
+ }
18
+ catch {
19
+ /* a closed stream must not fail the turn */
20
+ }
21
+ const result = await lines.next();
22
+ if (result.kind !== "line") {
23
+ // EOF or an oversized line cannot be an approval.
24
+ pendingExit = true;
25
+ return false;
26
+ }
27
+ if (isTerminator(result.text)) {
28
+ pendingExit = true;
29
+ return false;
30
+ }
31
+ return YES_RE.test(result.text.trim());
32
+ };
33
+ return Object.defineProperty(confirm, "pendingExit", {
34
+ get: () => pendingExit,
35
+ enumerable: true,
36
+ });
37
+ }