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,28 @@
1
+ import { z } from "zod";
2
+ import type { ToolDescriptor } from "./registry.js";
3
+ export declare function isSecretPath(p: string): boolean;
4
+ /** Convert a glob to a RegExp: `*`, `**`, `?`, `{a,b}` only. */
5
+ export declare function globToRegExp(pattern: string): RegExp;
6
+ declare const ReadFileArgs: z.ZodObject<{
7
+ path: z.ZodString;
8
+ maxBytes: z.ZodOptional<z.ZodNumber>;
9
+ }, z.core.$strip>;
10
+ export declare const readFileTool: ToolDescriptor<z.infer<typeof ReadFileArgs>>;
11
+ declare const GlobArgs: z.ZodObject<{
12
+ pattern: z.ZodString;
13
+ maxResults: z.ZodOptional<z.ZodNumber>;
14
+ }, z.core.$strip>;
15
+ export declare const globTool: ToolDescriptor<z.infer<typeof GlobArgs>>;
16
+ declare const GrepArgs: z.ZodObject<{
17
+ pattern: z.ZodString;
18
+ pathGlob: z.ZodOptional<z.ZodString>;
19
+ maxResults: z.ZodOptional<z.ZodNumber>;
20
+ }, z.core.$strip>;
21
+ export declare const grepTool: ToolDescriptor<z.infer<typeof GrepArgs>>;
22
+ declare const SearchArgs: z.ZodObject<{
23
+ query: z.ZodString;
24
+ maxResults: z.ZodOptional<z.ZodNumber>;
25
+ }, z.core.$strip>;
26
+ export declare const searchWorkspaceTool: ToolDescriptor<z.infer<typeof SearchArgs>>;
27
+ export declare const FS_TOOLS: readonly ToolDescriptor<any>[];
28
+ export {};
@@ -0,0 +1,292 @@
1
+ // xcoder:version 1 | xcoder:task task-f1b5f6 | xcoder:updated 2026-11-05T02:46:50.359Z | xcoder:subtask code-st-53c7b5
2
+ import fsp from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { z } from "zod";
5
+ import { byteLength, normalizeUntrusted, readBounded, redact, resolveInsideRoot, truncate, } from "../util/text.js";
6
+ /**
7
+ * Read-only filesystem tools (design §2.6).
8
+ *
9
+ * Every one of these is confined to the workspace and bounded in both output
10
+ * size and result count. None of them can write, delete, spawn a process or
11
+ * reach the network — those capabilities simply do not exist in this module,
12
+ * so no prompt text can conjure them (PRM-04, LLM08).
13
+ */
14
+ /** Paths whose contents must never enter the model context (PRM-03, LLM06). */
15
+ const SECRET_PATH_PATTERNS = [
16
+ /(^|[\\/])\.env(\.[^.\\/]+)?$/i,
17
+ /(^|[\\/])\.npmrc$/i,
18
+ /(^|[\\/])\.netrc$/i,
19
+ /(^|[\\/])id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/i,
20
+ /(^|[\\/])[^\\/]*\.(pem|key|p12|pfx|jks)$/i,
21
+ /(^|[\\/])\.ssh([\\/]|$)/i,
22
+ /(^|[\\/])\.aws([\\/]|$)/i,
23
+ /(^|[\\/])\.docker[\\/]config\.json$/i,
24
+ /(^|[\\/])credentials(\.json)?$/i,
25
+ ];
26
+ export function isSecretPath(p) {
27
+ return SECRET_PATH_PATTERNS.some((re) => re.test(p));
28
+ }
29
+ /** Directories never worth walking, and expensive if we did. */
30
+ const SKIP_DIRS = new Set([
31
+ "node_modules",
32
+ ".git",
33
+ "dist",
34
+ "build",
35
+ "coverage",
36
+ ".next",
37
+ ".turbo",
38
+ ".venv",
39
+ ]);
40
+ function aborted(ctx) {
41
+ if (ctx.signal?.aborted) {
42
+ return { ok: false, summary: "aborted by kill switch", bytes: 0, truncated: false };
43
+ }
44
+ return undefined;
45
+ }
46
+ /** Convert a glob to a RegExp: `*`, `**`, `?`, `{a,b}` only. */
47
+ export function globToRegExp(pattern) {
48
+ let out = "";
49
+ let i = 0;
50
+ while (i < pattern.length) {
51
+ const ch = pattern[i];
52
+ if (ch === "*") {
53
+ if (pattern[i + 1] === "*") {
54
+ out += ".*";
55
+ i += 2;
56
+ if (pattern[i] === "/")
57
+ i += 1;
58
+ continue;
59
+ }
60
+ out += "[^/]*";
61
+ i += 1;
62
+ continue;
63
+ }
64
+ if (ch === "?") {
65
+ out += "[^/]";
66
+ i += 1;
67
+ continue;
68
+ }
69
+ if (ch === "{") {
70
+ const close = pattern.indexOf("}", i);
71
+ if (close !== -1) {
72
+ const alts = pattern
73
+ .slice(i + 1, close)
74
+ .split(",")
75
+ .map((a) => a.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
76
+ out += `(?:${alts.join("|")})`;
77
+ i = close + 1;
78
+ continue;
79
+ }
80
+ }
81
+ out += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
82
+ i += 1;
83
+ }
84
+ return new RegExp(`^${out}$`);
85
+ }
86
+ async function walk(root, maxFiles) {
87
+ const out = [];
88
+ const stack = [root];
89
+ while (stack.length > 0 && out.length < maxFiles) {
90
+ const dir = stack.pop();
91
+ let entries;
92
+ try {
93
+ entries = await fsp.readdir(dir, { withFileTypes: true });
94
+ }
95
+ catch {
96
+ continue;
97
+ }
98
+ for (const e of entries) {
99
+ if (out.length >= maxFiles)
100
+ break;
101
+ const full = path.join(dir, e.name);
102
+ if (e.isDirectory()) {
103
+ if (SKIP_DIRS.has(e.name))
104
+ continue;
105
+ stack.push(full);
106
+ }
107
+ else if (e.isFile() && !isSecretPath(full)) {
108
+ out.push(full);
109
+ }
110
+ }
111
+ }
112
+ return out;
113
+ }
114
+ const ReadFileArgs = z.object({
115
+ path: z.string().min(1).max(1_000),
116
+ maxBytes: z.number().int().positive().max(1_048_576).optional(),
117
+ });
118
+ export const readFileTool = {
119
+ name: "read_file",
120
+ description: "Read a UTF-8 text file inside the workspace. Args: { path }. Secret-bearing paths (dotenv, keys, ssh, npmrc, credentials) are refused.",
121
+ risk: "read",
122
+ argsSchema: ReadFileArgs,
123
+ async execute(args, ctx) {
124
+ const stopped = aborted(ctx);
125
+ if (stopped)
126
+ return stopped;
127
+ if (isSecretPath(args.path)) {
128
+ return {
129
+ ok: false,
130
+ summary: `refused: ${args.path} is on the secret-path denylist`,
131
+ bytes: 0,
132
+ truncated: false,
133
+ };
134
+ }
135
+ const resolved = await resolveInsideRoot(ctx.workspaceRoot, args.path);
136
+ if (!resolved.ok) {
137
+ return { ok: false, summary: `refused: ${resolved.reason}`, bytes: 0, truncated: false };
138
+ }
139
+ if (isSecretPath(resolved.path)) {
140
+ return {
141
+ ok: false,
142
+ summary: "refused: resolved path is on the secret-path denylist",
143
+ bytes: 0,
144
+ truncated: false,
145
+ };
146
+ }
147
+ const cap = Math.min(args.maxBytes ?? ctx.limits.maxReadBytes, ctx.limits.maxReadBytes);
148
+ const read = await readBounded(resolved.path, cap);
149
+ if (!read.ok) {
150
+ return { ok: false, summary: `refused: ${read.reason}`, bytes: 0, truncated: false };
151
+ }
152
+ const cleaned = redact(normalizeUntrusted(read.text)).text;
153
+ const trimmed = truncate(cleaned, 20_000);
154
+ return {
155
+ ok: true,
156
+ summary: trimmed.text,
157
+ bytes: read.bytes,
158
+ truncated: read.truncated || trimmed.truncated,
159
+ data: { path: args.path, bytes: read.bytes },
160
+ };
161
+ },
162
+ };
163
+ const GlobArgs = z.object({
164
+ pattern: z.string().min(1).max(200),
165
+ maxResults: z.number().int().positive().max(200).optional(),
166
+ });
167
+ export const globTool = {
168
+ name: "glob",
169
+ description: "List workspace file paths matching a glob. Supports *, **, ? and {a,b} only. Args: { pattern, maxResults? }.",
170
+ risk: "read",
171
+ argsSchema: GlobArgs,
172
+ async execute(args, ctx) {
173
+ const stopped = aborted(ctx);
174
+ if (stopped)
175
+ return stopped;
176
+ const limit = Math.min(args.maxResults ?? 100, ctx.limits.maxResults);
177
+ const re = globToRegExp(args.pattern);
178
+ const files = await walk(ctx.workspaceRoot, 20_000);
179
+ const matches = [];
180
+ for (const f of files) {
181
+ const rel = path.relative(ctx.workspaceRoot, f).split(path.sep).join("/");
182
+ if (re.test(rel) || re.test(path.basename(rel))) {
183
+ matches.push(rel);
184
+ if (matches.length >= limit)
185
+ break;
186
+ }
187
+ }
188
+ return {
189
+ ok: true,
190
+ summary: matches.length > 0 ? matches.join("\n") : "no files matched",
191
+ bytes: byteLength(matches.join("\n")),
192
+ truncated: matches.length >= limit,
193
+ data: { count: matches.length },
194
+ };
195
+ },
196
+ };
197
+ const GrepArgs = z.object({
198
+ pattern: z.string().min(1).max(200),
199
+ pathGlob: z.string().min(1).max(200).optional(),
200
+ maxResults: z.number().int().positive().max(500).optional(),
201
+ });
202
+ export const grepTool = {
203
+ name: "grep",
204
+ description: "Search workspace file contents for a regular expression. Binary files are skipped. Args: { pattern, pathGlob?, maxResults? }.",
205
+ risk: "read",
206
+ argsSchema: GrepArgs,
207
+ async execute(args, ctx) {
208
+ const stopped = aborted(ctx);
209
+ if (stopped)
210
+ return stopped;
211
+ let re;
212
+ try {
213
+ re = new RegExp(args.pattern, "i");
214
+ }
215
+ catch {
216
+ return { ok: false, summary: "refused: pattern is not a valid regex", bytes: 0, truncated: false };
217
+ }
218
+ const limit = Math.min(args.maxResults ?? 100, ctx.limits.maxResults);
219
+ const pathFilter = args.pathGlob ? globToRegExp(args.pathGlob) : undefined;
220
+ const files = await walk(ctx.workspaceRoot, 20_000);
221
+ const hits = [];
222
+ for (const file of files) {
223
+ if (ctx.signal?.aborted)
224
+ break;
225
+ if (hits.length >= limit)
226
+ break;
227
+ const rel = path.relative(ctx.workspaceRoot, file).split(path.sep).join("/");
228
+ if (pathFilter && !pathFilter.test(rel) && !pathFilter.test(path.basename(rel)))
229
+ continue;
230
+ const read = await readBounded(file, 256_000);
231
+ if (!read.ok)
232
+ continue;
233
+ if (read.text.includes("\u0000"))
234
+ continue; // binary
235
+ const lines = read.text.split(/\r?\n/);
236
+ for (let i = 0; i < lines.length; i += 1) {
237
+ if (!re.test(lines[i]))
238
+ continue;
239
+ hits.push(`${rel}:${i + 1}: ${redact(lines[i]).text.slice(0, 300)}`);
240
+ if (hits.length >= limit)
241
+ break;
242
+ }
243
+ }
244
+ return {
245
+ ok: true,
246
+ summary: hits.length > 0 ? hits.join("\n") : "no matches",
247
+ bytes: byteLength(hits.join("\n")),
248
+ truncated: hits.length >= limit,
249
+ data: { count: hits.length },
250
+ };
251
+ },
252
+ };
253
+ const SearchArgs = z.object({
254
+ query: z.string().min(1).max(200),
255
+ maxResults: z.number().int().positive().max(200).optional(),
256
+ });
257
+ export const searchWorkspaceTool = {
258
+ name: "search_workspace",
259
+ description: "Search workspace file PATHS for a substring (cheap path index lookup, not content). Args: { query, maxResults? }.",
260
+ risk: "read",
261
+ argsSchema: SearchArgs,
262
+ async execute(args, ctx) {
263
+ const stopped = aborted(ctx);
264
+ if (stopped)
265
+ return stopped;
266
+ const limit = Math.min(args.maxResults ?? 100, ctx.limits.maxResults);
267
+ const needle = args.query.toLowerCase();
268
+ const files = await walk(ctx.workspaceRoot, 20_000);
269
+ const matches = [];
270
+ for (const f of files) {
271
+ const rel = path.relative(ctx.workspaceRoot, f).split(path.sep).join("/");
272
+ if (rel.toLowerCase().includes(needle)) {
273
+ matches.push(rel);
274
+ if (matches.length >= limit)
275
+ break;
276
+ }
277
+ }
278
+ return {
279
+ ok: true,
280
+ summary: matches.length > 0 ? matches.join("\n") : "no paths matched",
281
+ bytes: byteLength(matches.join("\n")),
282
+ truncated: matches.length >= limit,
283
+ data: { count: matches.length },
284
+ };
285
+ },
286
+ };
287
+ export const FS_TOOLS = [
288
+ readFileTool,
289
+ globTool,
290
+ grepTool,
291
+ searchWorkspaceTool,
292
+ ];
@@ -0,0 +1,27 @@
1
+ import { z } from "zod";
2
+ import type { ToolDescriptor } from "./registry.js";
3
+ /**
4
+ * Memory tools (design §2.6).
5
+ *
6
+ * `remember` is the only tool that mutates persistent state, so it is the one
7
+ * that respects the confirmation seam. It is also the only path by which the
8
+ * agent can carry something across sessions, and the value is redacted before
9
+ * it is written — so a key glimpsed in a file cannot be memorised verbatim.
10
+ */
11
+ declare const RememberArgs: z.ZodObject<{
12
+ value: z.ZodString;
13
+ kind: z.ZodDefault<z.ZodEnum<{
14
+ fact: "fact";
15
+ preference: "preference";
16
+ task: "task";
17
+ observation: "observation";
18
+ }>>;
19
+ key: z.ZodOptional<z.ZodString>;
20
+ }, z.core.$strip>;
21
+ export declare const rememberTool: ToolDescriptor<z.infer<typeof RememberArgs>>;
22
+ declare const RecallArgs: z.ZodObject<{
23
+ limit: z.ZodOptional<z.ZodNumber>;
24
+ }, z.core.$strip>;
25
+ export declare const recallTool: ToolDescriptor<z.infer<typeof RecallArgs>>;
26
+ export declare const MEMORY_TOOLS: readonly ToolDescriptor<any>[];
27
+ export {};
@@ -0,0 +1,83 @@
1
+ // xcoder:version 1 | xcoder:task task-f1b5f6 | xcoder:updated 2026-11-05T02:46:55.784Z | xcoder:subtask code-st-53c7b5
2
+ import { z } from "zod";
3
+ import { requireConfirmation } from "../util/confirm.js";
4
+ import { byteLength, truncate } from "../util/text.js";
5
+ /**
6
+ * Memory tools (design §2.6).
7
+ *
8
+ * `remember` is the only tool that mutates persistent state, so it is the one
9
+ * that respects the confirmation seam. It is also the only path by which the
10
+ * agent can carry something across sessions, and the value is redacted before
11
+ * it is written — so a key glimpsed in a file cannot be memorised verbatim.
12
+ */
13
+ const RememberArgs = z.object({
14
+ value: z.string().min(1).max(2_000),
15
+ kind: z.enum(["fact", "preference", "task", "observation"]).default("fact"),
16
+ key: z.string().min(1).max(128).optional(),
17
+ });
18
+ export const rememberTool = {
19
+ name: "remember",
20
+ description: "Save one short durable note for future sessions. Args: { value, kind?, key? }. Secrets are redacted before storage.",
21
+ risk: "write",
22
+ argsSchema: RememberArgs,
23
+ async execute(args, ctx) {
24
+ if (ctx.signal?.aborted) {
25
+ return { ok: false, summary: "aborted by kill switch", bytes: 0, truncated: false };
26
+ }
27
+ const allowed = await requireConfirmation(ctx.confirm, {
28
+ tool: "remember",
29
+ risk: "write",
30
+ summary: `${args.kind}: ${args.value.slice(0, 120)}`,
31
+ });
32
+ if (!allowed) {
33
+ return { ok: false, summary: "refused: memory write was not confirmed", bytes: 0, truncated: false };
34
+ }
35
+ const entry = await ctx.memory.append({
36
+ session: ctx.session,
37
+ kind: args.kind,
38
+ ...(args.key ? { key: args.key } : {}),
39
+ value: args.value,
40
+ source: "agent",
41
+ });
42
+ const preview = truncate(entry.value, 200).text;
43
+ return {
44
+ ok: true,
45
+ summary: `remembered ${args.kind}${args.key ? ` '${args.key}'` : ""}: ${preview}`,
46
+ bytes: byteLength(entry.value),
47
+ truncated: false,
48
+ data: { id: entry.id, kind: entry.kind, key: entry.key },
49
+ };
50
+ },
51
+ };
52
+ const RecallArgs = z.object({
53
+ limit: z.number().int().positive().max(100).optional(),
54
+ });
55
+ export const recallTool = {
56
+ name: "recall",
57
+ description: "Read back recent memory entries for this user and workspace. Args: { limit? }.",
58
+ risk: "read",
59
+ argsSchema: RecallArgs,
60
+ async execute(args, ctx) {
61
+ if (ctx.signal?.aborted) {
62
+ return { ok: false, summary: "aborted by kill switch", bytes: 0, truncated: false };
63
+ }
64
+ const limit = Math.min(args.limit ?? 20, ctx.limits.maxResults);
65
+ const entries = await ctx.memory.window(limit);
66
+ if (entries.length === 0) {
67
+ return { ok: true, summary: "no memory entries in scope", bytes: 0, truncated: false };
68
+ }
69
+ const lines = entries.map((e) => {
70
+ const key = e.key ? ` key=${e.key}` : "";
71
+ return `- ${e.ts} [${e.kind}${key}] ${truncate(e.value, 300).text}`;
72
+ });
73
+ const summary = lines.join("\n");
74
+ return {
75
+ ok: true,
76
+ summary,
77
+ bytes: byteLength(summary),
78
+ truncated: false,
79
+ data: { count: entries.length },
80
+ };
81
+ },
82
+ };
83
+ export const MEMORY_TOOLS = [rememberTool, recallTool];
@@ -0,0 +1,54 @@
1
+ import type { z } from "zod";
2
+ import type { MemoryStore } from "../memory/store.js";
3
+ import type { ConfirmFn, ToolRisk } from "../util/confirm.js";
4
+ export type { ToolRisk, ConfirmFn };
5
+ export interface ToolObservation {
6
+ ok: boolean;
7
+ summary: string;
8
+ data?: unknown;
9
+ bytes: number;
10
+ truncated: boolean;
11
+ }
12
+ export interface ToolLimits {
13
+ maxReadBytes: number;
14
+ maxResults: number;
15
+ }
16
+ export interface ToolContext {
17
+ workspaceRoot: string;
18
+ memory: MemoryStore;
19
+ session: string;
20
+ signal?: AbortSignal;
21
+ confirm: ConfirmFn;
22
+ limits: ToolLimits;
23
+ }
24
+ export interface ToolDescriptor<A = unknown> {
25
+ name: string;
26
+ description: string;
27
+ risk: ToolRisk;
28
+ argsSchema: z.ZodType<A>;
29
+ execute(args: A, ctx: ToolContext): Promise<ToolObservation>;
30
+ }
31
+ export interface ToolRegistry {
32
+ get(name: string): ToolDescriptor | undefined;
33
+ allowlist(): readonly string[];
34
+ describeForPrompt(): string;
35
+ }
36
+ export declare const DEFAULT_LIMITS: ToolLimits;
37
+ /**
38
+ * Pass-1 allowlist. Six narrow, side-effect-free tools (PRM-04, design §2.6).
39
+ *
40
+ * Deliberately absent: shell/exec, file write or delete, network fetch, git
41
+ * push, SQL. There is no open-ended 'run any command' surface to smuggle a
42
+ * model instruction into, and no irreversible tool exists in this build.
43
+ */
44
+ export declare const ALLOWED_TOOLS: readonly ["read_file", "glob", "grep", "search_workspace", "remember", "recall"];
45
+ export type AllowedToolName = (typeof ALLOWED_TOOLS)[number];
46
+ /**
47
+ * Static registry. The allowlist is enforced here, in code, not by prose in
48
+ * the prompt (PRM-09): `get()` returns undefined for any name not both
49
+ * registered and allowed, and the loop refuses rather than dispatching.
50
+ */
51
+ export declare function createToolRegistry(o?: {
52
+ tools?: readonly ToolDescriptor<any>[];
53
+ allow?: readonly string[];
54
+ }): ToolRegistry;
@@ -0,0 +1,49 @@
1
+ export const DEFAULT_LIMITS = {
2
+ maxReadBytes: 262_144,
3
+ maxResults: 200,
4
+ };
5
+ /**
6
+ * Pass-1 allowlist. Six narrow, side-effect-free tools (PRM-04, design §2.6).
7
+ *
8
+ * Deliberately absent: shell/exec, file write or delete, network fetch, git
9
+ * push, SQL. There is no open-ended 'run any command' surface to smuggle a
10
+ * model instruction into, and no irreversible tool exists in this build.
11
+ */
12
+ export const ALLOWED_TOOLS = [
13
+ "read_file",
14
+ "glob",
15
+ "grep",
16
+ "search_workspace",
17
+ "remember",
18
+ "recall",
19
+ ];
20
+ /**
21
+ * Static registry. The allowlist is enforced here, in code, not by prose in
22
+ * the prompt (PRM-09): `get()` returns undefined for any name not both
23
+ * registered and allowed, and the loop refuses rather than dispatching.
24
+ */
25
+ export function createToolRegistry(o = {}) {
26
+ const registered = new Map();
27
+ for (const t of o.tools ?? [])
28
+ registered.set(t.name, t);
29
+ const allowSet = new Set((o.allow && o.allow.length > 0 ? o.allow : ALLOWED_TOOLS).filter((n) => registered.has(n)));
30
+ return {
31
+ get(name) {
32
+ if (!allowSet.has(name))
33
+ return undefined;
34
+ return registered.get(name);
35
+ },
36
+ allowlist() {
37
+ return [...allowSet].sort();
38
+ },
39
+ describeForPrompt() {
40
+ return [...allowSet]
41
+ .sort()
42
+ .map((name) => {
43
+ const t = registered.get(name);
44
+ return t ? `- ${t.name}: ${t.description}` : `- ${name}`;
45
+ })
46
+ .join("\n");
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,28 @@
1
+ export type ToolRisk = "read" | "write" | "irreversible";
2
+ export interface ConfirmRequest {
3
+ tool: string;
4
+ risk: ToolRisk;
5
+ summary: string;
6
+ }
7
+ export type ConfirmFn = (r: ConfirmRequest) => Promise<boolean>;
8
+ /**
9
+ * Human-in-the-loop gate for high-impact actions (AI-03).
10
+ *
11
+ * Fails closed: when stdin is not a TTY (CI, piped input, an agent harness)
12
+ * there is nobody to ask, so the answer is `deny`. Pass-1 tools are all
13
+ * `read` or `write` and never reach this path; it exists so that adding an
14
+ * `irreversible` tool later cannot silently skip confirmation.
15
+ */
16
+ export declare const denyWhenNotTty: ConfirmFn;
17
+ export declare const alwaysDeny: ConfirmFn;
18
+ export declare function createConfirm(opts?: {
19
+ stdin?: NodeJS.ReadableStream & {
20
+ isTTY?: boolean;
21
+ };
22
+ stdout?: NodeJS.WritableStream;
23
+ }): ConfirmFn;
24
+ /**
25
+ * Enforce the gate. Read/write tools with no confirmation callback still
26
+ * proceed only for `read`; anything above `read` requires an explicit allow.
27
+ */
28
+ export declare function requireConfirmation(confirm: ConfirmFn, req: ConfirmRequest): Promise<boolean>;
@@ -0,0 +1,54 @@
1
+ // xcoder:version 1 | xcoder:task task-f1b5f6 | xcoder:updated 2026-11-05T02:44:35.687Z | xcoder:subtask code-st-53c7b5
2
+ import readline from "node:readline/promises";
3
+ /**
4
+ * Human-in-the-loop gate for high-impact actions (AI-03).
5
+ *
6
+ * Fails closed: when stdin is not a TTY (CI, piped input, an agent harness)
7
+ * there is nobody to ask, so the answer is `deny`. Pass-1 tools are all
8
+ * `read` or `write` and never reach this path; it exists so that adding an
9
+ * `irreversible` tool later cannot silently skip confirmation.
10
+ */
11
+ export const denyWhenNotTty = async () => false;
12
+ export const alwaysDeny = async () => false;
13
+ const RISK_LABEL = {
14
+ read: "reads data",
15
+ write: "modifies local state",
16
+ irreversible: "CANNOT BE UNDONE",
17
+ };
18
+ export function createConfirm(opts = {}) {
19
+ const stdin = opts.stdin ?? process.stdin;
20
+ const stdout = opts.stdout ?? process.stderr;
21
+ if (!stdin.isTTY)
22
+ return denyWhenNotTty;
23
+ return async (req) => {
24
+ const summary = req.summary.slice(0, 200).replace(/\s+/g, " ");
25
+ const rl = readline.createInterface({
26
+ input: stdin,
27
+ output: stdout,
28
+ });
29
+ try {
30
+ const answer = await rl.question(`jarvis: allow tool "${req.tool}" (${RISK_LABEL[req.risk]})? ${summary} [y/N] `);
31
+ return /^y(es)?$/i.test(answer.trim());
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ finally {
37
+ rl.close();
38
+ }
39
+ };
40
+ }
41
+ /**
42
+ * Enforce the gate. Read/write tools with no confirmation callback still
43
+ * proceed only for `read`; anything above `read` requires an explicit allow.
44
+ */
45
+ export async function requireConfirmation(confirm, req) {
46
+ if (req.risk === "read")
47
+ return true;
48
+ try {
49
+ return await confirm(req);
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
@@ -0,0 +1,10 @@
1
+ export interface KillState {
2
+ killed: boolean;
3
+ /** Which signal tripped, for provenance. Never contains file contents. */
4
+ reason?: "env" | "stop-file";
5
+ }
6
+ export declare function stopFilePath(workspaceRoot: string): string;
7
+ /** Never throws: an unreadable STOP path is treated as "not killed". */
8
+ export declare function readKillState(workspaceRoot: string): KillState;
9
+ /** Convenience predicate for `ReactLoopInput.isKilled`. */
10
+ export declare function createKillProbe(workspaceRoot: string): () => boolean;
@@ -0,0 +1,39 @@
1
+ // xcoder:version 1 | xcoder:task task-f1b5f6 | xcoder:updated 2026-11-05T02:44:31.387Z | xcoder:subtask code-st-53c7b5
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ /**
5
+ * Kill switch, fail-closed (design §4.3, AI-09).
6
+ *
7
+ * Jarvis aborts when either signal is present:
8
+ * - `XCODER_JARVIS_KILL` is a truthy env value, or
9
+ * - the workspace holds `.xcoder/jarvis/STOP`.
10
+ *
11
+ * The check runs before every LLM call, every tool dispatch and every memory
12
+ * write, so an operator can stop a runaway run without killing the process.
13
+ * It reads the filesystem on each call on purpose: the STOP file must take
14
+ * effect mid-run, which a value cached at startup could not do.
15
+ */
16
+ const TRUTHY = new Set(["1", "true", "yes", "on"]);
17
+ export function stopFilePath(workspaceRoot) {
18
+ return path.join(workspaceRoot, ".xcoder", "jarvis", "STOP");
19
+ }
20
+ /** Never throws: an unreadable STOP path is treated as "not killed". */
21
+ export function readKillState(workspaceRoot) {
22
+ const env = process.env["XCODER_JARVIS_KILL"];
23
+ if (env && TRUTHY.has(env.trim().toLowerCase())) {
24
+ return { killed: true, reason: "env" };
25
+ }
26
+ try {
27
+ if (fs.existsSync(stopFilePath(workspaceRoot))) {
28
+ return { killed: true, reason: "stop-file" };
29
+ }
30
+ }
31
+ catch {
32
+ // An environment we cannot inspect must not be reported as a tripped switch.
33
+ }
34
+ return { killed: false };
35
+ }
36
+ /** Convenience predicate for `ReactLoopInput.isKilled`. */
37
+ export function createKillProbe(workspaceRoot) {
38
+ return () => readKillState(workspaceRoot).killed;
39
+ }