xcoder-jarvis-agent 0.0.0-stage → 1.3.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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +224 -2
  3. package/dist/index.d.ts +27 -0
  4. package/dist/index.js +28 -0
  5. package/dist/jarvis/agent.d.ts +57 -0
  6. package/dist/jarvis/agent.js +306 -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 +38 -0
  18. package/dist/jarvis/chat/session.js +122 -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/memory/store.d.ts +88 -0
  24. package/dist/jarvis/memory/store.js +189 -0
  25. package/dist/jarvis/prompt/assemble.d.ts +61 -0
  26. package/dist/jarvis/prompt/assemble.js +118 -0
  27. package/dist/jarvis/prompt/policy.d.ts +22 -0
  28. package/dist/jarvis/prompt/policy.js +80 -0
  29. package/dist/jarvis/prompt/skills.d.ts +69 -0
  30. package/dist/jarvis/prompt/skills.js +257 -0
  31. package/dist/jarvis/react-loop.d.ts +51 -0
  32. package/dist/jarvis/react-loop.js +160 -0
  33. package/dist/jarvis/telemetry/provenance.d.ts +54 -0
  34. package/dist/jarvis/telemetry/provenance.js +56 -0
  35. package/dist/jarvis/test-helpers.d.ts +14 -0
  36. package/dist/jarvis/test-helpers.js +69 -0
  37. package/dist/jarvis/tools/fs-tools.d.ts +28 -0
  38. package/dist/jarvis/tools/fs-tools.js +292 -0
  39. package/dist/jarvis/tools/memory-tools.d.ts +27 -0
  40. package/dist/jarvis/tools/memory-tools.js +83 -0
  41. package/dist/jarvis/tools/registry.d.ts +54 -0
  42. package/dist/jarvis/tools/registry.js +49 -0
  43. package/dist/jarvis/util/confirm.d.ts +28 -0
  44. package/dist/jarvis/util/confirm.js +54 -0
  45. package/dist/jarvis/util/kill.d.ts +10 -0
  46. package/dist/jarvis/util/kill.js +39 -0
  47. package/dist/jarvis/util/text.d.ts +74 -0
  48. package/dist/jarvis/util/text.js +190 -0
  49. package/dist/program.d.ts +2 -0
  50. package/dist/program.js +12 -0
  51. package/package.json +35 -3
@@ -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
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Text and filesystem primitives shared by the prompt assembler, the tools and
3
+ * the memory store. Kept together so the injection and redaction behaviour is
4
+ * auditable in exactly one file (PRM-02, PRM-06, PRM-10).
5
+ */
6
+ export declare function sha256(input: string | Buffer): string;
7
+ export declare function shortHash(input: string, len?: number): string;
8
+ /**
9
+ * Normalise untrusted text before it enters a prompt (PRM-06).
10
+ *
11
+ * Strips zero-width/bidi controls, collapses CRLF to LF, and normalises to
12
+ * NFC. Deliberately does NOT rewrite ordinary text: the output still reads as
13
+ * the author wrote it, it just cannot carry invisible instructions.
14
+ */
15
+ export declare function normalizeUntrusted(text: string, maxChars?: number): string;
16
+ /**
17
+ * Escape a closing delimiter so untrusted text cannot break out of its block.
18
+ *
19
+ * An attacker who gets `</untrusted-skill>` into a skill pack could otherwise
20
+ * end the data block early and have the remainder read as instructions. We
21
+ * escape the angle brackets of any delimiter-shaped sequence rather than
22
+ * stripping it, so the text stays visible and reviewable.
23
+ */
24
+ export declare function escapeDelimiter(text: string, tag: string): string;
25
+ /** Escape every angle bracket/quoter — used for attribute values (name, sha). */
26
+ export declare function escapeAttr(value: string): string;
27
+ export interface Redacted {
28
+ text: string;
29
+ hits: number;
30
+ }
31
+ /** Redact secrets and report how many rules fired (CORE-07). */
32
+ export declare function redact(text: string): Redacted;
33
+ export declare function truncate(text: string, max: number): {
34
+ text: string;
35
+ truncated: boolean;
36
+ };
37
+ export declare function byteLength(text: string): number;
38
+ /**
39
+ * Short, filesystem-safe label derived from a user-supplied scope id.
40
+ *
41
+ * `--user` is an untrusted scope label, and it becomes a filename, so it must
42
+ * not be able to name a directory that resolves above the memory root. All
43
+ * separators become '-', runs of dots collapse to a single '.', and leading
44
+ * dots are stripped — that removes '.' and '..' as possible results, which
45
+ * are the only relative names that traverse.
46
+ */
47
+ export declare function sanitizeLabel(raw: string, fallback?: string): string;
48
+ /**
49
+ * Resolve `candidate` inside `root`, refusing paths that escape it.
50
+ *
51
+ * Uses `realpath` on both sides so a symlink pointing outside the workspace
52
+ * is rejected rather than followed (AI-04 path confinement).
53
+ */
54
+ export declare function resolveInsideRoot(root: string, candidate: string): Promise<{
55
+ ok: true;
56
+ path: string;
57
+ } | {
58
+ ok: false;
59
+ reason: string;
60
+ }>;
61
+ export declare function ensureDir(dir: string, mode?: number): Promise<void>;
62
+ export declare function writeFilePrivate(file: string, data: string): Promise<void>;
63
+ /** Write via temp file + rename so a reader never sees a half-written file. */
64
+ export declare function writeAtomic(file: string, data: string): Promise<void>;
65
+ /** Read a file, refusing anything larger than `maxBytes` before allocating. */
66
+ export declare function readBounded(file: string, maxBytes: number): Promise<{
67
+ ok: true;
68
+ text: string;
69
+ bytes: number;
70
+ truncated: boolean;
71
+ } | {
72
+ ok: false;
73
+ reason: string;
74
+ }>;
@@ -0,0 +1,190 @@
1
+ // xcoder:version 2 | xcoder:task task-f1b5f6 | xcoder:updated 2026-11-05T02:49:12.254Z | xcoder:subtask code-st-53c7b5
2
+ import crypto from "node:crypto";
3
+ import fsp from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { redactSecrets } from "xcoder-dna";
6
+ /**
7
+ * Text and filesystem primitives shared by the prompt assembler, the tools and
8
+ * the memory store. Kept together so the injection and redaction behaviour is
9
+ * auditable in exactly one file (PRM-02, PRM-06, PRM-10).
10
+ */
11
+ export function sha256(input) {
12
+ return crypto.createHash("sha256").update(input).digest("hex");
13
+ }
14
+ export function shortHash(input, len = 16) {
15
+ return sha256(input).slice(0, len);
16
+ }
17
+ /** Zero-width and bidi-control characters used to hide instructions. */
18
+ const ZERO_WIDTH = /[\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]/g;
19
+ /**
20
+ * Normalise untrusted text before it enters a prompt (PRM-06).
21
+ *
22
+ * Strips zero-width/bidi controls, collapses CRLF to LF, and normalises to
23
+ * NFC. Deliberately does NOT rewrite ordinary text: the output still reads as
24
+ * the author wrote it, it just cannot carry invisible instructions.
25
+ */
26
+ export function normalizeUntrusted(text, maxChars = 200_000) {
27
+ const clipped = text.length > maxChars ? text.slice(0, maxChars) : text;
28
+ return clipped.replace(ZERO_WIDTH, "").replace(/\r\n?/g, "\n").normalize("NFC");
29
+ }
30
+ const ENTITIES = {
31
+ "<": "&lt;",
32
+ ">": "&gt;",
33
+ "&": "&amp;",
34
+ '"': "&quot;",
35
+ "'": "&#39;",
36
+ };
37
+ /**
38
+ * Escape a closing delimiter so untrusted text cannot break out of its block.
39
+ *
40
+ * An attacker who gets `</untrusted-skill>` into a skill pack could otherwise
41
+ * end the data block early and have the remainder read as instructions. We
42
+ * escape the angle brackets of any delimiter-shaped sequence rather than
43
+ * stripping it, so the text stays visible and reviewable.
44
+ */
45
+ export function escapeDelimiter(text, tag) {
46
+ const pattern = new RegExp(`<\\s*/?\\s*${tag}\\b`, "gi");
47
+ return text.replace(pattern, (m) => m.replace(/[<>]/g, (c) => ENTITIES[c] ?? c));
48
+ }
49
+ /** Escape every angle bracket/quoter — used for attribute values (name, sha). */
50
+ export function escapeAttr(value) {
51
+ return value.replace(/[<>&"']/g, (c) => ENTITIES[c] ?? c);
52
+ }
53
+ /** Redact secrets and report how many rules fired (CORE-07). */
54
+ export function redact(text) {
55
+ try {
56
+ const result = redactSecrets(text);
57
+ const hits = (result.redactions ?? []).reduce((n, r) => n + (r.count ?? 0), 0);
58
+ return { text: result.text, hits };
59
+ }
60
+ catch {
61
+ // redactSecrets is documented never to throw; if that ever changes, the
62
+ // safe action for a logging path is to withhold the text entirely.
63
+ return { text: "[REDACTED:redaction-failed]", hits: 1 };
64
+ }
65
+ }
66
+ export function truncate(text, max) {
67
+ if (text.length <= max)
68
+ return { text, truncated: false };
69
+ return { text: text.slice(0, max), truncated: true };
70
+ }
71
+ export function byteLength(text) {
72
+ return Buffer.byteLength(text, "utf8");
73
+ }
74
+ /**
75
+ * Short, filesystem-safe label derived from a user-supplied scope id.
76
+ *
77
+ * `--user` is an untrusted scope label, and it becomes a filename, so it must
78
+ * not be able to name a directory that resolves above the memory root. All
79
+ * separators become '-', runs of dots collapse to a single '.', and leading
80
+ * dots are stripped — that removes '.' and '..' as possible results, which
81
+ * are the only relative names that traverse.
82
+ */
83
+ export function sanitizeLabel(raw, fallback = "unknown") {
84
+ const cleaned = raw
85
+ .normalize("NFKC")
86
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
87
+ .replace(/\.{2,}/g, ".")
88
+ .replace(/^[.-]+|[.-]+$/g, "")
89
+ .slice(0, 64);
90
+ return cleaned.length > 0 ? cleaned : fallback;
91
+ }
92
+ /**
93
+ * Resolve `candidate` inside `root`, refusing paths that escape it.
94
+ *
95
+ * Uses `realpath` on both sides so a symlink pointing outside the workspace
96
+ * is rejected rather than followed (AI-04 path confinement).
97
+ */
98
+ export async function resolveInsideRoot(root, candidate) {
99
+ let realRoot;
100
+ try {
101
+ realRoot = await fsp.realpath(root);
102
+ }
103
+ catch {
104
+ return { ok: false, reason: "workspace root is not readable" };
105
+ }
106
+ const target = path.resolve(realRoot, candidate);
107
+ let realTarget;
108
+ try {
109
+ realTarget = await fsp.realpath(target);
110
+ }
111
+ catch {
112
+ return { ok: false, reason: "path does not exist" };
113
+ }
114
+ const rel = path.relative(realRoot, realTarget);
115
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
116
+ return { ok: false, reason: "path escapes the workspace" };
117
+ }
118
+ return { ok: true, path: realTarget };
119
+ }
120
+ export async function ensureDir(dir, mode = 0o700) {
121
+ await fsp.mkdir(dir, { recursive: true, mode });
122
+ // Windows ignores POSIX modes; best-effort is documented in the README.
123
+ if (process.platform !== "win32") {
124
+ try {
125
+ await fsp.chmod(dir, mode);
126
+ }
127
+ catch {
128
+ /* best effort */
129
+ }
130
+ }
131
+ }
132
+ export async function writeFilePrivate(file, data) {
133
+ await ensureDir(path.dirname(file));
134
+ await fsp.writeFile(file, data, { encoding: "utf8", mode: 0o600 });
135
+ if (process.platform !== "win32") {
136
+ try {
137
+ await fsp.chmod(file, 0o600);
138
+ }
139
+ catch {
140
+ /* best effort */
141
+ }
142
+ }
143
+ }
144
+ /** Write via temp file + rename so a reader never sees a half-written file. */
145
+ export async function writeAtomic(file, data) {
146
+ await ensureDir(path.dirname(file));
147
+ const tmp = `${file}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
148
+ await fsp.writeFile(tmp, data, { encoding: "utf8", mode: 0o600 });
149
+ await fsp.rename(tmp, file);
150
+ if (process.platform !== "win32") {
151
+ try {
152
+ await fsp.chmod(file, 0o600);
153
+ }
154
+ catch {
155
+ /* best effort */
156
+ }
157
+ }
158
+ }
159
+ /** Read a file, refusing anything larger than `maxBytes` before allocating. */
160
+ export async function readBounded(file, maxBytes) {
161
+ let stat;
162
+ try {
163
+ stat = await fsp.stat(file);
164
+ }
165
+ catch {
166
+ return { ok: false, reason: "file does not exist" };
167
+ }
168
+ if (!stat.isFile())
169
+ return { ok: false, reason: "not a regular file" };
170
+ const limit = Math.min(stat.size, maxBytes);
171
+ try {
172
+ const handle = await fsp.open(file, "r");
173
+ try {
174
+ const buf = Buffer.alloc(limit);
175
+ const { bytesRead } = await handle.read(buf, 0, limit, 0);
176
+ return {
177
+ ok: true,
178
+ text: buf.subarray(0, bytesRead).toString("utf8"),
179
+ bytes: bytesRead,
180
+ truncated: stat.size > limit,
181
+ };
182
+ }
183
+ finally {
184
+ await handle.close();
185
+ }
186
+ }
187
+ catch {
188
+ return { ok: false, reason: "file could not be read" };
189
+ }
190
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ // xcoder:version 1 | xcoder:task task-f1b5f6 | xcoder:updated 2026-11-05T02:47:38.121Z | xcoder:subtask code-st-53c7b5
3
+ import { runJarvisAgent } from "./index.js";
4
+ /**
5
+ * Process boundary only (design §2.1).
6
+ *
7
+ * Everything testable lives behind `runJarvisAgent`; this file exists so the
8
+ * manifest's `bin.jarvis` entry has something to point at, and so the exit
9
+ * code the CLI contract promises is the one the process actually returns.
10
+ */
11
+ const code = await runJarvisAgent(process.argv.slice(2));
12
+ process.exit(code);
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "xcoder-jarvis-agent",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.3.0",
4
+ "description": "Jarvis — a ReAct agent with scoped memory, skill-pack prompt assembly and a code-enforced tool allowlist, built on xcoder-dna.",
5
+ "license": "MIT",
6
+ "author": "Neural Node Labs",
7
+ "type": "module",
8
+ "engines": {
9
+ "node": ">=20"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js"
23
+ },
24
+ "./package.json": "./package.json"
25
+ },
26
+ "bin": {
27
+ "jarvis": "./dist/program.js"
28
+ },
29
+ "scripts": {
30
+ "build": "tsc -p tsconfig.json",
31
+ "typecheck": "tsc --noEmit -p tsconfig.json",
32
+ "test": "vitest run"
33
+ },
34
+ "dependencies": {
35
+ "xcoder-dna": "^1.3.0",
36
+ "zod": "^4.4.3"
37
+ }
6
38
  }