amness 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +212 -2
- package/dist/cli/acpcli.d.ts +7 -0
- package/dist/cli/acpcli.js +14 -0
- package/dist/cli/amness.d.ts +2 -0
- package/dist/cli/amness.js +693 -0
- package/dist/cli/benchcli.d.ts +5 -0
- package/dist/cli/benchcli.js +160 -0
- package/dist/cli/config.d.ts +79 -0
- package/dist/cli/config.js +54 -0
- package/dist/cli/demo.d.ts +13 -0
- package/dist/cli/demo.js +44 -0
- package/dist/cli/detect.d.ts +2 -0
- package/dist/cli/detect.js +20 -0
- package/dist/cli/evalcli.d.ts +15 -0
- package/dist/cli/evalcli.js +198 -0
- package/dist/cli/format.d.ts +5 -0
- package/dist/cli/format.js +55 -0
- package/dist/cli/headless.d.ts +2 -0
- package/dist/cli/headless.js +169 -0
- package/dist/cli/model.d.ts +11 -0
- package/dist/cli/model.js +19 -0
- package/dist/cli/plugincli.d.ts +2 -0
- package/dist/cli/plugincli.js +176 -0
- package/dist/cli/schedulecli.d.ts +2 -0
- package/dist/cli/schedulecli.js +66 -0
- package/dist/cli/state.d.ts +11 -0
- package/dist/cli/state.js +19 -0
- package/dist/cli/tui/ansi.d.ts +38 -0
- package/dist/cli/tui/ansi.js +142 -0
- package/dist/cli/tui/app.d.ts +328 -0
- package/dist/cli/tui/app.js +1358 -0
- package/dist/cli/tui/blocks.d.ts +151 -0
- package/dist/cli/tui/blocks.js +128 -0
- package/dist/cli/tui/brand.d.ts +9 -0
- package/dist/cli/tui/brand.js +16 -0
- package/dist/cli/tui/canvas.d.ts +92 -0
- package/dist/cli/tui/canvas.js +199 -0
- package/dist/cli/tui/cards.d.ts +44 -0
- package/dist/cli/tui/cards.js +215 -0
- package/dist/cli/tui/clipboard.d.ts +5 -0
- package/dist/cli/tui/clipboard.js +29 -0
- package/dist/cli/tui/commands.d.ts +10 -0
- package/dist/cli/tui/commands.js +649 -0
- package/dist/cli/tui/complete.d.ts +39 -0
- package/dist/cli/tui/complete.js +113 -0
- package/dist/cli/tui/custom.d.ts +9 -0
- package/dist/cli/tui/custom.js +34 -0
- package/dist/cli/tui/diffview.d.ts +25 -0
- package/dist/cli/tui/diffview.js +130 -0
- package/dist/cli/tui/editor.d.ts +60 -0
- package/dist/cli/tui/editor.js +230 -0
- package/dist/cli/tui/full.d.ts +86 -0
- package/dist/cli/tui/full.js +630 -0
- package/dist/cli/tui/keys.d.ts +31 -0
- package/dist/cli/tui/keys.js +134 -0
- package/dist/cli/tui/markdown.d.ts +37 -0
- package/dist/cli/tui/markdown.js +155 -0
- package/dist/cli/tui/mentions.d.ts +15 -0
- package/dist/cli/tui/mentions.js +48 -0
- package/dist/cli/tui/modal.d.ts +26 -0
- package/dist/cli/tui/modal.js +107 -0
- package/dist/cli/tui/overlays.d.ts +98 -0
- package/dist/cli/tui/overlays.js +237 -0
- package/dist/cli/tui/pager.d.ts +15 -0
- package/dist/cli/tui/pager.js +66 -0
- package/dist/cli/tui/pricing.d.ts +9 -0
- package/dist/cli/tui/pricing.js +23 -0
- package/dist/cli/tui/rich.d.ts +16 -0
- package/dist/cli/tui/rich.js +72 -0
- package/dist/cli/tui/screen.d.ts +30 -0
- package/dist/cli/tui/screen.js +62 -0
- package/dist/cli/tui/session.d.ts +77 -0
- package/dist/cli/tui/session.js +103 -0
- package/dist/cli/tui/sidebar.d.ts +84 -0
- package/dist/cli/tui/sidebar.js +114 -0
- package/dist/cli/tui/text.d.ts +15 -0
- package/dist/cli/tui/text.js +94 -0
- package/dist/cli/tui/theme.d.ts +59 -0
- package/dist/cli/tui/theme.js +148 -0
- package/dist/cli/tui/themes.d.ts +56 -0
- package/dist/cli/tui/themes.js +80 -0
- package/dist/cli/tui/tokens.d.ts +39 -0
- package/dist/cli/tui/tokens.js +71 -0
- package/dist/cli/tui/view.d.ts +60 -0
- package/dist/cli/tui/view.js +178 -0
- package/dist/cli/ui.d.ts +34 -0
- package/dist/cli/ui.js +224 -0
- package/dist/src/acp.d.ts +28 -0
- package/dist/src/acp.js +111 -0
- package/dist/src/agents.d.ts +23 -0
- package/dist/src/agents.js +71 -0
- package/dist/src/anthropic.d.ts +30 -0
- package/dist/src/anthropic.js +91 -0
- package/dist/src/approver.d.ts +15 -0
- package/dist/src/approver.js +40 -0
- package/dist/src/bench.d.ts +67 -0
- package/dist/src/bench.js +201 -0
- package/dist/src/bundle.d.ts +36 -0
- package/dist/src/bundle.js +55 -0
- package/dist/src/chaos.d.ts +15 -0
- package/dist/src/chaos.js +26 -0
- package/dist/src/code.d.ts +187 -0
- package/dist/src/code.js +590 -0
- package/dist/src/codeindex.d.ts +65 -0
- package/dist/src/codeindex.js +300 -0
- package/dist/src/deep.d.ts +62 -0
- package/dist/src/deep.js +177 -0
- package/dist/src/diff.d.ts +8 -0
- package/dist/src/diff.js +74 -0
- package/dist/src/dynamic.d.ts +42 -0
- package/dist/src/dynamic.js +133 -0
- package/dist/src/embed.d.ts +8 -0
- package/dist/src/embed.js +25 -0
- package/dist/src/evalcases.d.ts +11 -0
- package/dist/src/evalcases.js +196 -0
- package/dist/src/evals.d.ts +197 -0
- package/dist/src/evals.js +340 -0
- package/dist/src/gitflow.d.ts +27 -0
- package/dist/src/gitflow.js +56 -0
- package/dist/src/grade.d.ts +42 -0
- package/dist/src/grade.js +96 -0
- package/dist/src/guidance.d.ts +15 -0
- package/dist/src/guidance.js +68 -0
- package/dist/src/hardcases.d.ts +2 -0
- package/dist/src/hardcases.js +94 -0
- package/dist/src/harness.d.ts +411 -0
- package/dist/src/harness.js +725 -0
- package/dist/src/hooks.d.ts +44 -0
- package/dist/src/hooks.js +116 -0
- package/dist/src/images.d.ts +13 -0
- package/dist/src/images.js +27 -0
- package/dist/src/index.d.ts +57 -0
- package/dist/src/index.js +57 -0
- package/dist/src/ledger.d.ts +58 -0
- package/dist/src/ledger.js +78 -0
- package/dist/src/live.d.ts +16 -0
- package/dist/src/live.js +80 -0
- package/dist/src/lsp.d.ts +31 -0
- package/dist/src/lsp.js +147 -0
- package/dist/src/memory.d.ts +128 -0
- package/dist/src/memory.js +390 -0
- package/dist/src/metrics.d.ts +3 -0
- package/dist/src/metrics.js +42 -0
- package/dist/src/mining.d.ts +49 -0
- package/dist/src/mining.js +126 -0
- package/dist/src/openai.d.ts +50 -0
- package/dist/src/openai.js +307 -0
- package/dist/src/parallel.d.ts +66 -0
- package/dist/src/parallel.js +135 -0
- package/dist/src/patch.d.ts +45 -0
- package/dist/src/patch.js +219 -0
- package/dist/src/permissions.d.ts +46 -0
- package/dist/src/permissions.js +245 -0
- package/dist/src/plugins.d.ts +148 -0
- package/dist/src/plugins.js +225 -0
- package/dist/src/pluginscan.d.ts +8 -0
- package/dist/src/pluginscan.js +20 -0
- package/dist/src/procs.d.ts +41 -0
- package/dist/src/procs.js +94 -0
- package/dist/src/profiles.d.ts +36 -0
- package/dist/src/profiles.js +61 -0
- package/dist/src/progress.d.ts +29 -0
- package/dist/src/progress.js +75 -0
- package/dist/src/prompts.d.ts +10 -0
- package/dist/src/prompts.js +28 -0
- package/dist/src/ratelimit.d.ts +34 -0
- package/dist/src/ratelimit.js +116 -0
- package/dist/src/replay.d.ts +25 -0
- package/dist/src/replay.js +99 -0
- package/dist/src/review.d.ts +20 -0
- package/dist/src/review.js +49 -0
- package/dist/src/route.d.ts +9 -0
- package/dist/src/route.js +30 -0
- package/dist/src/router.d.ts +61 -0
- package/dist/src/router.js +126 -0
- package/dist/src/sandbox.d.ts +13 -0
- package/dist/src/sandbox.js +68 -0
- package/dist/src/schedule.d.ts +20 -0
- package/dist/src/schedule.js +59 -0
- package/dist/src/schema.d.ts +13 -0
- package/dist/src/schema.js +36 -0
- package/dist/src/secrets.d.ts +10 -0
- package/dist/src/secrets.js +41 -0
- package/dist/src/shell.d.ts +34 -0
- package/dist/src/shell.js +95 -0
- package/dist/src/skills.d.ts +20 -0
- package/dist/src/skills.js +112 -0
- package/dist/src/snapshot.d.ts +20 -0
- package/dist/src/snapshot.js +93 -0
- package/dist/src/stats.d.ts +24 -0
- package/dist/src/stats.js +62 -0
- package/dist/src/strategies.d.ts +16 -0
- package/dist/src/strategies.js +35 -0
- package/dist/src/suites.d.ts +30 -0
- package/dist/src/suites.js +112 -0
- package/dist/src/trace.d.ts +63 -0
- package/dist/src/trace.js +153 -0
- package/dist/src/tune.d.ts +38 -0
- package/dist/src/tune.js +49 -0
- package/dist/src/web.d.ts +19 -0
- package/dist/src/web.js +56 -0
- package/dist/src/worktree.d.ts +24 -0
- package/dist/src/worktree.js +85 -0
- package/docs/agent-loop.md +222 -0
- package/docs/architecture.md +228 -0
- package/docs/bench.md +293 -0
- package/docs/budgets.md +185 -0
- package/docs/build-your-own-harness.md +424 -0
- package/docs/cookbook.md +375 -0
- package/docs/development.md +150 -0
- package/docs/evaluation.md +450 -0
- package/docs/features.md +1320 -0
- package/docs/getting-started.md +196 -0
- package/docs/headless.md +183 -0
- package/docs/images/approval.png +0 -0
- package/docs/images/chat.png +0 -0
- package/docs/images/daylight.png +0 -0
- package/docs/images/loop.png +0 -0
- package/docs/images/palette.png +0 -0
- package/docs/images/running.png +0 -0
- package/docs/images/subagent-done.png +0 -0
- package/docs/images/subagent-running.png +0 -0
- package/docs/images/welcome.png +0 -0
- package/docs/index.md +25 -0
- package/docs/library.md +276 -0
- package/docs/plugins.md +691 -0
- package/docs/providers.md +175 -0
- package/docs/safety.md +237 -0
- package/docs/usage.md +348 -0
- package/docs/why.md +155 -0
- package/docs/writing-guide.md +39 -0
- package/package.json +72 -4
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// OS-level sandbox for shell commands. The permission prompts are a policy; this is enforcement: even an approved or auto-approved command cannot write outside
|
|
2
|
+
// the workspace (or into .git), and in strict mode cannot use the network. macOS: Seatbelt (sandbox-exec). Linux: Bubblewrap (bwrap). Elsewhere: unavailable, and
|
|
3
|
+
// asking for a sandbox that cannot be provided is an error, never a silent downgrade.
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
6
|
+
import { homedir, tmpdir } from "node:os";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
8
|
+
export const SANDBOX_MODES = ["off", "workspace", "strict"];
|
|
9
|
+
let cached = null;
|
|
10
|
+
const has = (bin) => { try {
|
|
11
|
+
execFileSync("sh", ["-c", `command -v ${bin}`], { stdio: "ignore" });
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
} };
|
|
17
|
+
/** bwrap is often installed but unable to create namespaces (containers, locked-down kernels): probe with a no-op instead of trusting `command -v`. */
|
|
18
|
+
function bwrapWorks() { try {
|
|
19
|
+
execFileSync("bwrap", ["--ro-bind", "/", "/", "--dev", "/dev", "true"], { stdio: "ignore", timeout: 5000 });
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
} }
|
|
25
|
+
/** Which sandbox this machine can provide. */
|
|
26
|
+
export function sandboxKind() {
|
|
27
|
+
if (cached === null)
|
|
28
|
+
cached = process.platform === "darwin" && has("sandbox-exec") ? "seatbelt" : process.platform === "linux" && has("bwrap") && bwrapWorks() ? "bwrap" : undefined;
|
|
29
|
+
return cached;
|
|
30
|
+
}
|
|
31
|
+
export const sandboxHelp = () => process.platform === "linux" ? "install bubblewrap (apt install bubblewrap); if it is installed, this kernel or container does not allow it to create namespaces" : process.platform === "darwin" ? "sandbox-exec was not found" : "OS sandboxing is only available on macOS and Linux";
|
|
32
|
+
const real = (p) => { try {
|
|
33
|
+
return realpathSync(p);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return resolve(p);
|
|
37
|
+
} };
|
|
38
|
+
/** Folders that tools legitimately write to besides the workspace: temp folders and package-manager caches (not in strict mode). */
|
|
39
|
+
function writable(workspace, mode) {
|
|
40
|
+
const h = homedir(), base = [workspace, tmpdir(), "/tmp", "/private/tmp", "/private/var/folders", "/var/folders", "/dev/shm"];
|
|
41
|
+
const caches = [".npm", ".cache", ".cargo", ".rustup", ".gradle", ".m2", ".pnpm-store", ".yarn", ".bun", ".deno", "go/pkg", ".local/share", "Library/Caches", ".node-gyp", ".config"].map((d) => join(h, d));
|
|
42
|
+
return [...new Set([...base, ...(mode === "workspace" ? caches : [])].map(real))].filter((p) => existsSync(p));
|
|
43
|
+
}
|
|
44
|
+
const q = (s) => JSON.stringify(s); // SBPL string literals use the same quoting as JSON for the characters that matter here
|
|
45
|
+
/** The Seatbelt profile: everything allowed except writing outside the allowed folders (and inside .git), and, in strict mode, the network. */
|
|
46
|
+
export function seatbeltProfile(workspace, mode) {
|
|
47
|
+
const ws = real(workspace), dirs = writable(ws, mode);
|
|
48
|
+
return [
|
|
49
|
+
"(version 1)", "(allow default)",
|
|
50
|
+
"(deny file-write*)",
|
|
51
|
+
`(allow file-write* ${dirs.map((d) => `(subpath ${q(d)})`).join(" ")} (literal "/dev/null") (literal "/dev/tty") (literal "/dev/dtracehelper") (regex #"^/dev/(fd|pts|tty)")` + ")",
|
|
52
|
+
`(deny file-write* (subpath ${q(join(ws, ".git"))}) (regex #"^${join(ws, ".amness").replace(/[.+*?^${}()|[\]\\]/g, "\\$&")}/(hooks|permissions)\\.json$"))`,
|
|
53
|
+
...(mode === "strict" ? ["(deny network*)", "(allow network* (local unix))"] : []),
|
|
54
|
+
].join("\n");
|
|
55
|
+
}
|
|
56
|
+
/** `file` and `args` that run `cmd` under the sandbox, or undefined when mode is off. Throws when a sandbox was asked for and cannot be provided. */
|
|
57
|
+
export function wrapCommand(cmd, workspace, mode) {
|
|
58
|
+
if (mode === "off")
|
|
59
|
+
return undefined;
|
|
60
|
+
const kind = sandboxKind();
|
|
61
|
+
if (!kind)
|
|
62
|
+
throw new Error(`the sandbox is on (${mode}) but unavailable here: ${sandboxHelp()}. Turn it off with /sandbox off to run commands unsandboxed.`);
|
|
63
|
+
if (kind === "seatbelt")
|
|
64
|
+
return { file: "sandbox-exec", args: ["-p", seatbeltProfile(workspace, mode), "sh", "-c", cmd] };
|
|
65
|
+
const ws = real(workspace), dirs = writable(ws, mode);
|
|
66
|
+
return { file: "bwrap", args: ["--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", ...dirs.flatMap((d) => ["--bind", d, d]), ...(existsSync(join(ws, ".git")) ? ["--ro-bind", join(ws, ".git"), join(ws, ".git")] : []),
|
|
67
|
+
...(mode === "strict" ? ["--unshare-net"] : []), "--die-with-parent", "--chdir", ws, "sh", "-c", cmd] };
|
|
68
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type Schedule = {
|
|
2
|
+
name: string;
|
|
3
|
+
cron: string;
|
|
4
|
+
task: string;
|
|
5
|
+
verify?: string;
|
|
6
|
+
extra?: string[];
|
|
7
|
+
};
|
|
8
|
+
/** Strict 5-field cron check (numbers, ranges, lists, steps, `*`). Names like MON are not accepted, to keep what runs unambiguous. */
|
|
9
|
+
export declare function validCron(expr: string): string | undefined;
|
|
10
|
+
export declare const file: (cwd: string) => string;
|
|
11
|
+
export declare function load(cwd: string): Schedule[];
|
|
12
|
+
export declare function save(cwd: string, list: Schedule[]): void;
|
|
13
|
+
export declare function add(cwd: string, s: Schedule): void;
|
|
14
|
+
export declare function remove(cwd: string, name: string): boolean;
|
|
15
|
+
/** The crontab lines for these schedules. Runs are denied risky commands (no --yes) unless the schedule says otherwise in `extra`. */
|
|
16
|
+
export declare function cronLines(cwd: string, list: Schedule[], bin?: string): string[];
|
|
17
|
+
/** `current` crontab text with this project's block replaced by `lines` (or removed when empty). Everything else is kept byte for byte. */
|
|
18
|
+
export declare function mergeCrontab(current: string, cwd: string, lines: string[]): string;
|
|
19
|
+
export declare function ensureLogDir(cwd: string): void;
|
|
20
|
+
export declare const hasSchedules: (cwd: string) => boolean;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Recurring headless runs. Schedules live in .amness/schedules.json; `amness schedule install` turns them into crontab lines (printed by default; written only with --write,
|
|
2
|
+
// inside a marked block so removing them is exact). Each run is `amness run --json` with its output kept in .amness/schedule-logs/.
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
const RANGES = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]];
|
|
6
|
+
/** Strict 5-field cron check (numbers, ranges, lists, steps, `*`). Names like MON are not accepted, to keep what runs unambiguous. */
|
|
7
|
+
export function validCron(expr) {
|
|
8
|
+
const f = expr.trim().split(/\s+/);
|
|
9
|
+
if (f.length !== 5)
|
|
10
|
+
return "a schedule needs 5 fields: minute hour day-of-month month day-of-week";
|
|
11
|
+
for (let i = 0; i < 5; i++)
|
|
12
|
+
for (const part of f[i].split(",")) {
|
|
13
|
+
const m = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(part);
|
|
14
|
+
if (!m)
|
|
15
|
+
return `field ${i + 1} (${f[i]}) is not valid`;
|
|
16
|
+
const [lo, hi] = RANGES[i], nums = m[1] === "*" ? [] : m[1].split("-").map(Number);
|
|
17
|
+
if (nums.some((n) => n < lo || n > hi) || (nums.length === 2 && nums[0] > nums[1]))
|
|
18
|
+
return `field ${i + 1} (${f[i]}) is out of range ${lo}-${hi}`;
|
|
19
|
+
if (m[2] !== undefined && Number(m[2]) < 1)
|
|
20
|
+
return `field ${i + 1} step must be at least 1`;
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
const sq = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
25
|
+
export const file = (cwd) => join(cwd, ".amness", "schedules.json");
|
|
26
|
+
export function load(cwd) { try {
|
|
27
|
+
return JSON.parse(readFileSync(file(cwd), "utf8"));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return [];
|
|
31
|
+
} }
|
|
32
|
+
export function save(cwd, list) { mkdirSync(dirname(file(cwd)), { recursive: true }); writeFileSync(file(cwd), JSON.stringify(list, null, 2) + "\n"); }
|
|
33
|
+
export function add(cwd, s) {
|
|
34
|
+
const bad = validCron(s.cron);
|
|
35
|
+
if (bad)
|
|
36
|
+
throw new Error(bad);
|
|
37
|
+
if (!/^[\w.-]{1,40}$/.test(s.name))
|
|
38
|
+
throw new Error("the name may use letters, numbers, . _ - (40 characters at most)");
|
|
39
|
+
if (!s.task.trim())
|
|
40
|
+
throw new Error("a schedule needs a task");
|
|
41
|
+
const list = load(cwd).filter((x) => x.name !== s.name);
|
|
42
|
+
list.push(s);
|
|
43
|
+
save(cwd, list);
|
|
44
|
+
}
|
|
45
|
+
export function remove(cwd, name) { const l = load(cwd), n = l.filter((x) => x.name !== name); save(cwd, n); return n.length !== l.length; }
|
|
46
|
+
const BEGIN = (cwd) => `# >>> amness schedules for ${cwd} >>>`, END = (cwd) => `# <<< amness schedules for ${cwd} <<<`;
|
|
47
|
+
/** The crontab lines for these schedules. Runs are denied risky commands (no --yes) unless the schedule says otherwise in `extra`. */
|
|
48
|
+
export function cronLines(cwd, list, bin = "amness") {
|
|
49
|
+
return list.map((s) => `${s.cron} cd ${sq(cwd)} && ${bin} run --json ${s.verify ? `--verify ${sq(s.verify)} ` : ""}${(s.extra ?? []).map(sq).join(" ")}${s.extra?.length ? " " : ""}${sq(s.task)} >> ${sq(join(cwd, ".amness", "schedule-logs", s.name + ".jsonl"))} 2>&1`);
|
|
50
|
+
}
|
|
51
|
+
/** `current` crontab text with this project's block replaced by `lines` (or removed when empty). Everything else is kept byte for byte. */
|
|
52
|
+
export function mergeCrontab(current, cwd, lines) {
|
|
53
|
+
const b = BEGIN(cwd), e = END(cwd), i = current.indexOf(b), j = current.indexOf(e);
|
|
54
|
+
const rest = i >= 0 && j > i ? current.slice(0, i) + current.slice(j + e.length).replace(/^\n/, "") : current;
|
|
55
|
+
const base = rest.replace(/\n*$/, rest.trim() ? "\n" : "");
|
|
56
|
+
return lines.length ? `${base}${b}\n${lines.join("\n")}\n${e}\n` : base;
|
|
57
|
+
}
|
|
58
|
+
export function ensureLogDir(cwd) { mkdirSync(join(cwd, ".amness", "schedule-logs"), { recursive: true }); }
|
|
59
|
+
export const hasSchedules = (cwd) => existsSync(file(cwd));
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Minimal JSON-schema subset: type, enum, required, properties, additionalProperties, items. Returns error strings. */
|
|
2
|
+
export type Schema = {
|
|
3
|
+
type?: "string" | "number" | "integer" | "boolean" | "array" | "object" | "null";
|
|
4
|
+
enum?: unknown[];
|
|
5
|
+
properties?: Record<string, Schema>;
|
|
6
|
+
required?: string[];
|
|
7
|
+
additionalProperties?: boolean;
|
|
8
|
+
items?: Schema;
|
|
9
|
+
description?: string;
|
|
10
|
+
};
|
|
11
|
+
export declare function validate(v: unknown, s: Schema, path?: string): string[];
|
|
12
|
+
/** Parse JSON the way models actually emit it: strip ``` fences and surrounding prose. */
|
|
13
|
+
export declare function parseLooseJson(text: string): unknown;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const typeOf = (v) => (v === null ? "null" : Array.isArray(v) ? "array" : typeof v);
|
|
2
|
+
export function validate(v, s, path = "$") {
|
|
3
|
+
const errs = [];
|
|
4
|
+
const t = typeOf(v);
|
|
5
|
+
if (s.type && !(t === s.type || (s.type === "integer" && Number.isInteger(v)) || (s.type === "number" && t === "number")))
|
|
6
|
+
return [`${path}: expected ${s.type}, got ${t}`];
|
|
7
|
+
if (s.enum && !s.enum.includes(v))
|
|
8
|
+
errs.push(`${path}: must be one of ${JSON.stringify(s.enum)}`);
|
|
9
|
+
if (t === "object") {
|
|
10
|
+
const o = v;
|
|
11
|
+
for (const k of s.required ?? [])
|
|
12
|
+
if (!(k in o))
|
|
13
|
+
errs.push(`${path}.${k}: required`);
|
|
14
|
+
for (const [k, x] of Object.entries(o)) {
|
|
15
|
+
if (s.properties?.[k])
|
|
16
|
+
errs.push(...validate(x, s.properties[k], `${path}.${k}`));
|
|
17
|
+
else if (s.additionalProperties === false)
|
|
18
|
+
errs.push(`${path}.${k}: unexpected property`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (t === "array" && s.items)
|
|
22
|
+
v.forEach((x, i) => errs.push(...validate(x, s.items, `${path}[${i}]`)));
|
|
23
|
+
return errs;
|
|
24
|
+
}
|
|
25
|
+
/** Parse JSON the way models actually emit it: strip ``` fences and surrounding prose. */
|
|
26
|
+
export function parseLooseJson(text) {
|
|
27
|
+
const t = text.trim().replace(/^```(?:json)?\s*|\s*```$/g, "");
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(t);
|
|
30
|
+
}
|
|
31
|
+
catch { /* fall through to brace scan */ }
|
|
32
|
+
const i = t.search(/[{[]/), j = Math.max(t.lastIndexOf("}"), t.lastIndexOf("]"));
|
|
33
|
+
if (i >= 0 && j > i)
|
|
34
|
+
return JSON.parse(t.slice(i, j + 1));
|
|
35
|
+
throw new Error("no JSON found");
|
|
36
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type Redacted = {
|
|
2
|
+
text: string;
|
|
3
|
+
count: number;
|
|
4
|
+
kinds: string[];
|
|
5
|
+
};
|
|
6
|
+
export declare function redact(text: string): Redacted;
|
|
7
|
+
/** `redact` applied to every string inside a JSON-like value (used before anything is written to a trace). */
|
|
8
|
+
export declare function redactDeep<T>(v: T): T;
|
|
9
|
+
/** Environment variables a command run by the agent should not inherit: Amness's own keys always; with `strict`, anything that looks like a credential. */
|
|
10
|
+
export declare function childEnv(env?: NodeJS.ProcessEnv, strict?: boolean): NodeJS.ProcessEnv;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const PATTERNS = [
|
|
2
|
+
["anthropic-key", /\bsk-ant-[A-Za-z0-9_-]{20,}/g], ["openai-key", /\bsk-(?:proj-)?[A-Za-z0-9_-]{24,}/g], ["github-token", /\bgh[pousr]_[A-Za-z0-9]{30,}\b|\bgithub_pat_[A-Za-z0-9_]{40,}/g],
|
|
3
|
+
["aws-access-key", /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g], ["slack-token", /\bxox[baprs]-[A-Za-z0-9-]{10,}/g], ["google-api-key", /\bAIza[0-9A-Za-z_-]{35}\b/g], ["stripe-key", /\b[rs]k_(?:live|test)_[0-9a-zA-Z]{20,}/g],
|
|
4
|
+
["npm-token", /\bnpm_[A-Za-z0-9]{36}\b/g], ["private-key", /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g], ["jwt", /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g],
|
|
5
|
+
];
|
|
6
|
+
/** `PASSWORD=hunter2hunter2`, `"api_key": "abcd1234efgh"`, `Authorization: Bearer xyz...` : keep the name, mask the value. */
|
|
7
|
+
const ASSIGN = /((?:^|[\s"'`{,])(?:[A-Za-z0-9_.-]*(?:pass(?:word|wd)?|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|credential|auth)[A-Za-z0-9_.-]*)["'`]?\s*[:=]\s*["'`]?)([^\s"'`,;}{]{8,})/gi;
|
|
8
|
+
const BEARER = /(\bBearer\s+)([A-Za-z0-9._~+/=-]{16,})/g;
|
|
9
|
+
export function redact(text) {
|
|
10
|
+
let count = 0;
|
|
11
|
+
const kinds = new Set();
|
|
12
|
+
let out = text;
|
|
13
|
+
for (const [kind, re] of PATTERNS)
|
|
14
|
+
out = out.replace(re, () => { count++; kinds.add(kind); return `[REDACTED:${kind}]`; });
|
|
15
|
+
out = out.replace(BEARER, (_m, pre) => { count++; kinds.add("bearer-token"); return `${pre}[REDACTED:bearer-token]`; });
|
|
16
|
+
out = out.replace(ASSIGN, (m, pre, val) => { if (/^\[REDACTED|^\$\{?\w+\}?$|^<[^>]+>$|^(?:true|false|null|undefined|none)$/i.test(val) || /^(?:process\.env|os\.environ|getenv|ENV\[)/.test(val))
|
|
17
|
+
return m; count++; kinds.add("credential-assignment"); return `${pre}[REDACTED:credential]`; });
|
|
18
|
+
return { text: out, count, kinds: [...kinds] };
|
|
19
|
+
}
|
|
20
|
+
/** `redact` applied to every string inside a JSON-like value (used before anything is written to a trace). */
|
|
21
|
+
export function redactDeep(v) {
|
|
22
|
+
if (typeof v === "string")
|
|
23
|
+
return redact(v).text;
|
|
24
|
+
if (Array.isArray(v))
|
|
25
|
+
return v.map(redactDeep);
|
|
26
|
+
if (v && typeof v === "object")
|
|
27
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, redactDeep(x)]));
|
|
28
|
+
return v;
|
|
29
|
+
}
|
|
30
|
+
/** Environment variables a command run by the agent should not inherit: Amness's own keys always; with `strict`, anything that looks like a credential. */
|
|
31
|
+
export function childEnv(env = process.env, strict = false) {
|
|
32
|
+
const out = {};
|
|
33
|
+
for (const [k, v] of Object.entries(env)) {
|
|
34
|
+
if (/^(OPENAI_API_KEY|ANTHROPIC_API_KEY|TAVILY_API_KEY|OPENROUTER_API_KEY|AMNESS_[A-Z_]*KEY|AMNESS_OTLP)$/.test(k))
|
|
35
|
+
continue;
|
|
36
|
+
if (strict && /(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL)/i.test(k) && !/^(SSH_AUTH_SOCK|GPG_TTY|KEYCHAIN.*|DISPLAY)$/.test(k))
|
|
37
|
+
continue;
|
|
38
|
+
out[k] = v;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type ShellKind = "posix" | "powershell" | "cmd";
|
|
2
|
+
export type ShellInfo = {
|
|
3
|
+
name: string;
|
|
4
|
+
kind: ShellKind;
|
|
5
|
+
file: string; /** how the shell is started with one command line */
|
|
6
|
+
args: (cmd: string) => string[];
|
|
7
|
+
};
|
|
8
|
+
export declare const SHELL_CHOICES: readonly string[];
|
|
9
|
+
type Probe = {
|
|
10
|
+
platform: NodeJS.Platform;
|
|
11
|
+
env: Record<string, string | undefined>;
|
|
12
|
+
exists: (f: string) => boolean;
|
|
13
|
+
};
|
|
14
|
+
/** Pick a shell. `want` is "auto" or a name; a name that is not installed falls back to auto (and `fallback` says so). Pure apart from the probe, so it is testable. */
|
|
15
|
+
export declare function chooseShell(want?: string, p?: Probe): {
|
|
16
|
+
shell: ShellInfo;
|
|
17
|
+
fallback?: string;
|
|
18
|
+
};
|
|
19
|
+
/** Set the preferred shell (from config or /shell). Anything else than a known name is ignored. */
|
|
20
|
+
export declare function setShell(name: string | undefined): void;
|
|
21
|
+
export declare function currentShell(): ShellInfo;
|
|
22
|
+
export declare const shellFallbackNote: () => string | undefined;
|
|
23
|
+
/** The program and arguments that run one command line in the current shell. */
|
|
24
|
+
export declare const shellInvocation: (cmd: string) => {
|
|
25
|
+
file: string;
|
|
26
|
+
args: string[];
|
|
27
|
+
};
|
|
28
|
+
/** Kept for older callers: the path of the current shell program. */
|
|
29
|
+
export declare const posixShell: () => string;
|
|
30
|
+
/** What to tell the model about where its commands run. */
|
|
31
|
+
export declare function shellNote(p?: Probe): string;
|
|
32
|
+
/** The path as the model should see it: always forward slashes. */
|
|
33
|
+
export declare const slash: (p: string) => string;
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Which shell runs the agent's commands. The best choice depends on the machine, so Amness looks at what exists:
|
|
2
|
+
// macOS / Linux: bash, else zsh, else sh (the model writes bash; your login shell is not used unless you ask for it).
|
|
3
|
+
// Windows: the bash that comes with Git for Windows, else PowerShell 7 (pwsh), else Windows PowerShell, else cmd.exe.
|
|
4
|
+
// The model is told which shell it is talking to (a PowerShell command is not a bash command), and the safety floor knows the dangerous commands of each family.
|
|
5
|
+
// Override with the AMNESS_SHELL environment variable, the `shell` setting, or /shell: auto, bash, zsh, sh, pwsh, powershell or cmd.
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { delimiter, join as joinHere, win32 } from "node:path";
|
|
8
|
+
const jn = (win) => (win ? win32.join : joinHere);
|
|
9
|
+
const WANT = ["auto", "bash", "zsh", "sh", "pwsh", "powershell", "cmd"];
|
|
10
|
+
export const SHELL_CHOICES = WANT;
|
|
11
|
+
const real = { platform: process.platform, get env() { return process.env; }, exists: existsSync };
|
|
12
|
+
const exe = (n, win) => (win && !/\.(exe|cmd|bat)$/i.test(n) ? n + ".exe" : n);
|
|
13
|
+
/** Find a program on PATH (never the WSL launcher in System32, which sees another filesystem). */
|
|
14
|
+
function onPath(name, p) {
|
|
15
|
+
const win = p.platform === "win32", dirs = (p.env.PATH ?? p.env.Path ?? "").split(win ? ";" : delimiter).filter(Boolean);
|
|
16
|
+
for (const d of dirs) {
|
|
17
|
+
const f = jn(win)(d, exe(name, win));
|
|
18
|
+
if (p.exists(f) && !(win && name === "bash" && /system32/i.test(f)))
|
|
19
|
+
return f;
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
function gitBash(p) {
|
|
24
|
+
const join = jn(p.platform === "win32");
|
|
25
|
+
const fromPath = onPath("bash", p);
|
|
26
|
+
if (fromPath)
|
|
27
|
+
return fromPath;
|
|
28
|
+
for (const base of [p.env.ProgramFiles, p.env["ProgramFiles(x86)"], p.env.LOCALAPPDATA && jn(true)(p.env.LOCALAPPDATA, "Programs")])
|
|
29
|
+
if (base)
|
|
30
|
+
for (const sub of [join("Git", "bin", "bash.exe"), join("Git", "usr", "bin", "bash.exe")]) {
|
|
31
|
+
const f = join(base, sub);
|
|
32
|
+
if (p.exists(f))
|
|
33
|
+
return f;
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
const posix = (name, file) => ({ name, kind: "posix", file, args: (cmd) => ["-c", cmd] });
|
|
38
|
+
const ps = (name, file) => ({ name, kind: "powershell", file, args: (cmd) => ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", cmd] });
|
|
39
|
+
const cmdShell = (file) => ({ name: "cmd", kind: "cmd", file, args: (cmd) => ["/d", "/s", "/c", cmd] });
|
|
40
|
+
/** Pick a shell. `want` is "auto" or a name; a name that is not installed falls back to auto (and `fallback` says so). Pure apart from the probe, so it is testable. */
|
|
41
|
+
export function chooseShell(want = "auto", p = real) {
|
|
42
|
+
const win = p.platform === "win32";
|
|
43
|
+
const find = (n) => {
|
|
44
|
+
if (n === "cmd")
|
|
45
|
+
return win ? cmdShell(p.env.ComSpec ?? "cmd.exe") : undefined;
|
|
46
|
+
if (n === "pwsh") {
|
|
47
|
+
const f = onPath("pwsh", p);
|
|
48
|
+
return f ? ps("pwsh", f) : undefined;
|
|
49
|
+
}
|
|
50
|
+
if (n === "powershell") {
|
|
51
|
+
const f = onPath("powershell", p) ?? (win && p.env.SystemRoot && p.exists(win32.join(p.env.SystemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")) ? win32.join(p.env.SystemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe") : undefined);
|
|
52
|
+
return f ? ps("powershell", f) : undefined;
|
|
53
|
+
}
|
|
54
|
+
if (n === "bash" && win) {
|
|
55
|
+
const f = gitBash(p);
|
|
56
|
+
return f ? posix("bash", f) : undefined;
|
|
57
|
+
}
|
|
58
|
+
const f = onPath(n, p) ?? (!win && p.exists("/bin/" + n) ? "/bin/" + n : undefined);
|
|
59
|
+
return f ? posix(n, f) : undefined;
|
|
60
|
+
};
|
|
61
|
+
let fallback;
|
|
62
|
+
if (want !== "auto") {
|
|
63
|
+
const s = find(want);
|
|
64
|
+
if (s)
|
|
65
|
+
return { shell: s };
|
|
66
|
+
fallback = `${want} was asked for but is not installed here; using the automatic choice`;
|
|
67
|
+
}
|
|
68
|
+
if (win) {
|
|
69
|
+
const s = find("bash") ?? find("pwsh") ?? find("powershell") ?? find("cmd") ?? cmdShell("cmd.exe");
|
|
70
|
+
return { shell: s, fallback };
|
|
71
|
+
}
|
|
72
|
+
// bash first (it is what models write), then zsh, then sh. Your login shell is deliberately not used by default: commands should behave the same on every machine; ask for zsh with /shell zsh.
|
|
73
|
+
const s = find("bash") ?? find("zsh") ?? find("sh") ?? posix("sh", "/bin/sh");
|
|
74
|
+
return { shell: s, fallback };
|
|
75
|
+
}
|
|
76
|
+
let wanted = "auto", cached;
|
|
77
|
+
/** Set the preferred shell (from config or /shell). Anything else than a known name is ignored. */
|
|
78
|
+
export function setShell(name) { const n = (name ?? "auto").toLowerCase(); wanted = WANT.includes(n) ? n : "auto"; cached = undefined; }
|
|
79
|
+
export function currentShell() { return (cached ??= chooseShell(process.env.AMNESS_SHELL ?? wanted)).shell; }
|
|
80
|
+
export const shellFallbackNote = () => (cached ??= chooseShell(process.env.AMNESS_SHELL ?? wanted)).fallback;
|
|
81
|
+
/** The program and arguments that run one command line in the current shell. */
|
|
82
|
+
export const shellInvocation = (cmd) => { const s = currentShell(); return { file: s.file, args: s.args(cmd) }; };
|
|
83
|
+
/** Kept for older callers: the path of the current shell program. */
|
|
84
|
+
export const posixShell = () => currentShell().file;
|
|
85
|
+
/** What to tell the model about where its commands run. */
|
|
86
|
+
export function shellNote(p = real) {
|
|
87
|
+
const s = chooseShell(process.env.AMNESS_SHELL ?? wanted, p).shell, os = p.platform === "win32" ? "Windows" : p.platform === "darwin" ? "macOS" : p.platform === "linux" ? "Linux" : p.platform;
|
|
88
|
+
if (s.kind === "powershell")
|
|
89
|
+
return `Environment: ${os}. Commands run in PowerShell (${s.name}), not bash: use PowerShell syntax (Get-ChildItem, Select-String, Get-Content, $env:NAME, \`;\` to chain commands). Forward slashes work in paths. Tools like grep, ls and cat may not exist.`;
|
|
90
|
+
if (s.kind === "cmd")
|
|
91
|
+
return `Environment: ${os}. Commands run in cmd.exe, not bash: use cmd syntax (dir, type, set NAME=value, & to chain commands). Tools like grep, ls and cat do not exist.`;
|
|
92
|
+
return `Environment: ${os}. Commands run with ${s.name} (${s.name} -c "<cmd>"): use ${s.name === "sh" || s.name === "dash" ? "POSIX sh" : s.name} syntax.`;
|
|
93
|
+
}
|
|
94
|
+
/** The path as the model should see it: always forward slashes. */
|
|
95
|
+
export const slash = (p) => p.replace(/\\/g, "/");
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type Tool } from "./harness.ts";
|
|
2
|
+
export type Skill = {
|
|
3
|
+
name: string;
|
|
4
|
+
description: string;
|
|
5
|
+
dir: string;
|
|
6
|
+
file: string;
|
|
7
|
+
scope: "project" | "user";
|
|
8
|
+
source: string;
|
|
9
|
+
};
|
|
10
|
+
/** All skills visible from `cwd`: project folders first, then the user's; for the same name the first convention found wins. Malformed skills are skipped and reported. */
|
|
11
|
+
export declare function loadSkills(cwd: string, o?: {
|
|
12
|
+
userHome?: string;
|
|
13
|
+
}): {
|
|
14
|
+
skills: Skill[];
|
|
15
|
+
warnings: string[];
|
|
16
|
+
};
|
|
17
|
+
/** The short list that goes into the prompt. */
|
|
18
|
+
export declare function skillsBlock(skills: Skill[]): string;
|
|
19
|
+
/** `skill({ name })` returns the instructions plus the list of bundled files; `skill({ name, file })` returns one of those files. Nothing outside the skill's folder can be read. */
|
|
20
|
+
export declare function skillTool(get: () => Skill[]): Tool;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Skills: folders with a SKILL.md (name + description in front matter, instructions in the body, optional helper files). Only the name and one-line description go
|
|
2
|
+
// in the prompt; the model loads the full text with the `skill` tool when it needs it ("progressive disclosure"), so many skills cost almost nothing until used.
|
|
3
|
+
// Compatible folder conventions: .amness/skills, .agents/skills (Codex, Gemini CLI, OpenCode) and .claude/skills (Claude Code), in the project and in your home folder.
|
|
4
|
+
import { slash } from "./shell.js";
|
|
5
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
8
|
+
import { defineTool } from "./harness.js";
|
|
9
|
+
const CONVENTIONS = [".amness/skills", ".agents/skills", ".claude/skills"];
|
|
10
|
+
function parseFront(text) {
|
|
11
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
|
|
12
|
+
if (!m)
|
|
13
|
+
return { meta: {}, body: text };
|
|
14
|
+
const meta = {};
|
|
15
|
+
let key = "";
|
|
16
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
17
|
+
const kv = /^([\w-]+):\s*(.*)$/.exec(line);
|
|
18
|
+
if (kv) {
|
|
19
|
+
key = kv[1];
|
|
20
|
+
meta[key] = kv[2].replace(/^["']|["']$/g, "").trim();
|
|
21
|
+
}
|
|
22
|
+
else if (key && /^\s+\S/.test(line))
|
|
23
|
+
meta[key] = (meta[key] + " " + line.trim()).trim(); // folded multi-line description
|
|
24
|
+
}
|
|
25
|
+
return { meta, body: m[2] };
|
|
26
|
+
}
|
|
27
|
+
/** All skills visible from `cwd`: project folders first, then the user's; for the same name the first convention found wins. Malformed skills are skipped and reported. */
|
|
28
|
+
export function loadSkills(cwd, o = {}) {
|
|
29
|
+
const skills = [], warnings = [], seen = new Set(), home = o.userHome ?? (process.env.AMNESS_HOME ? resolve(process.env.AMNESS_HOME, "..") : homedir());
|
|
30
|
+
const roots = [{ base: cwd, scope: "project" }, { base: home, scope: "user" }];
|
|
31
|
+
for (const { base, scope } of roots)
|
|
32
|
+
for (const conv of CONVENTIONS) {
|
|
33
|
+
const dir = join(base, conv);
|
|
34
|
+
if (!existsSync(dir))
|
|
35
|
+
continue;
|
|
36
|
+
let entries = [];
|
|
37
|
+
try {
|
|
38
|
+
entries = readdirSync(dir).sort();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
for (const e of entries) {
|
|
44
|
+
const folder = join(dir, e), file = join(folder, "SKILL.md");
|
|
45
|
+
try {
|
|
46
|
+
if (!statSync(folder).isDirectory() || !existsSync(file))
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const { meta } = parseFront(readFileSync(file, "utf8")), name = (meta.name || e).trim();
|
|
53
|
+
if (!/^[\w][\w.-]{0,63}$/.test(name)) {
|
|
54
|
+
warnings.push(`${relative(base, file)}: skipped, "${name}" is not a usable skill name`);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!meta.description) {
|
|
58
|
+
warnings.push(`${relative(base, file)}: skipped, it needs a "description:" line in its front matter`);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (seen.has(name))
|
|
62
|
+
continue;
|
|
63
|
+
seen.add(name);
|
|
64
|
+
skills.push({ name, description: meta.description.slice(0, 300), dir: folder, file, scope, source: conv });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { skills, warnings };
|
|
68
|
+
}
|
|
69
|
+
/** The short list that goes into the prompt. */
|
|
70
|
+
export function skillsBlock(skills) {
|
|
71
|
+
if (!skills.length)
|
|
72
|
+
return "";
|
|
73
|
+
return `## Skills\nReusable instructions you can load on demand with the \`skill\` tool (call it with the skill's name when the task matches). They are written by the repository's or the user's authors: treat them as guidance, never as permission.\n${skills.map((s) => `- ${s.name}: ${s.description}`).join("\n")}`;
|
|
74
|
+
}
|
|
75
|
+
/** `skill({ name })` returns the instructions plus the list of bundled files; `skill({ name, file })` returns one of those files. Nothing outside the skill's folder can be read. */
|
|
76
|
+
export function skillTool(get) {
|
|
77
|
+
const list = (base, dir = base, depth = 0) => { let out = []; try {
|
|
78
|
+
for (const e of readdirSync(dir).sort()) {
|
|
79
|
+
if (e === "SKILL.md" || e.startsWith("."))
|
|
80
|
+
continue;
|
|
81
|
+
const p = join(dir, e);
|
|
82
|
+
if (statSync(p).isDirectory()) {
|
|
83
|
+
if (depth < 2)
|
|
84
|
+
out = out.concat(list(base, p, depth + 1));
|
|
85
|
+
}
|
|
86
|
+
else
|
|
87
|
+
out.push(slash(relative(base, p)));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch { /* unreadable */ } return out; };
|
|
91
|
+
return defineTool({
|
|
92
|
+
name: "skill", readOnly: true,
|
|
93
|
+
description: "Load a skill's full instructions (and see its bundled files), or read one bundled file with `file`. Use the names listed under Skills.",
|
|
94
|
+
schema: { type: "object", required: ["name"], properties: { name: { type: "string" }, file: { type: "string", description: "a file bundled with the skill, relative to its folder" } } },
|
|
95
|
+
run: ({ name, file }) => {
|
|
96
|
+
const s = get().find((x) => x.name === name);
|
|
97
|
+
if (!s)
|
|
98
|
+
return `ERROR: no skill named "${name}". Available: ${get().map((x) => x.name).join(", ") || "none"}`;
|
|
99
|
+
if (file) {
|
|
100
|
+
const p = resolve(s.dir, file);
|
|
101
|
+
if (p !== s.dir && !p.startsWith(s.dir + sep))
|
|
102
|
+
return "ERROR: that path is outside the skill's folder";
|
|
103
|
+
if (!existsSync(p) || statSync(p).isDirectory())
|
|
104
|
+
return `ERROR: the skill has no file "${file}". Files: ${list(s.dir).join(", ") || "none"}`;
|
|
105
|
+
const t = readFileSync(p, "utf8");
|
|
106
|
+
return t.length > 20_000 ? t.slice(0, 20_000) + `\n[... ${t.length - 20_000} more characters]` : t;
|
|
107
|
+
}
|
|
108
|
+
const { body } = parseFront(readFileSync(s.file, "utf8")), files = list(s.dir), text = body.trim().length > 20_000 ? body.trim().slice(0, 20_000) + "\n[... truncated]" : body.trim();
|
|
109
|
+
return `# Skill: ${s.name}\n(skill folder: ${s.dir})\n\n${text}${files.length ? `\n\nBundled files (read one with skill({ name: "${s.name}", file })): ${files.join(", ")}` : ""}`;
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare function irreversibleReason(cmd: string): string | undefined;
|
|
2
|
+
export type SnapshotResult = {
|
|
3
|
+
restored: string[];
|
|
4
|
+
removed: string[];
|
|
5
|
+
};
|
|
6
|
+
export declare class Snapshots {
|
|
7
|
+
private cwd;
|
|
8
|
+
private ok?;
|
|
9
|
+
private gitDir;
|
|
10
|
+
constructor(cwd: string);
|
|
11
|
+
/** Snapshots need a git repository (with or without commits). */
|
|
12
|
+
available(): Promise<boolean>;
|
|
13
|
+
private index;
|
|
14
|
+
/** Record the working tree as a tree object and return its id (undefined when there is no git repository or git fails). */
|
|
15
|
+
take(): Promise<string | undefined>;
|
|
16
|
+
/** Paths in the working tree that differ from the snapshot (changed, deleted, or created since). */
|
|
17
|
+
changedSince(tree: string): Promise<string[]>;
|
|
18
|
+
/** Put the working tree back to `tree`: restore files that were changed or deleted, remove files that were created (only those git would see: ignored files are never touched). */
|
|
19
|
+
restore(tree: string): Promise<SnapshotResult>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Reversible shell commands. Before an agent runs a command that may change files, the whole working tree (tracked and untracked, ignored files excluded) is recorded as a git
|
|
2
|
+
// TREE OBJECT using a private index file: no commit, no branch, no stash, the user's index and history are never touched. `restoreTree` puts the working tree back exactly
|
|
3
|
+
// (files restored, files the command created removed). Effects outside the workspace and on other systems cannot be restored, so such commands are named instead.
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs";
|
|
6
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
const git = (cwd, args, env = {}, input) => new Promise((res, rej) => { const p = execFile("git", args, { cwd, env: { ...process.env, ...env }, maxBuffer: 256 << 20 }, (e, out, err) => (e ? rej(new Error((err || e.message).trim().split("\n")[0])) : res(out))); if (input !== undefined)
|
|
9
|
+
p.stdin?.end(input); });
|
|
10
|
+
const EXCLUDE = [":(exclude).amness", ":(exclude).git"];
|
|
11
|
+
/** Commands whose effects reach beyond the working tree, so undo cannot take them back. */
|
|
12
|
+
const IRREVERSIBLE = [
|
|
13
|
+
[/\bgit\s+(?:-\S+\s+)*push\b/, "pushes commits to a remote"], [/\b(?:npm|pnpm|yarn)\s+(?:publish|deprecate|unpublish)\b|\bcargo\s+publish\b|\btwine\s+upload\b|\bgem\s+push\b/, "publishes a package"],
|
|
14
|
+
[/\bdocker\s+(?:push|rm|rmi|system\s+prune|volume\s+rm)\b|\bdocker-compose\s+down\s+-v\b/, "changes or deletes containers or images"], [/\bkubectl\s+(?:apply|delete|replace|scale|rollout|patch)\b|\bhelm\s+(?:install|upgrade|uninstall)\b|\bterraform\s+(?:apply|destroy)\b|\bpulumi\s+(?:up|destroy)\b/, "changes live infrastructure"],
|
|
15
|
+
[/\baws\s+\S+\s+(?:create|delete|put|update|terminate|remove|run|start|stop)\w*|\bgcloud\s+.*\b(?:create|delete|update|deploy)\b|\baz\s+.*\b(?:create|delete|update)\b/, "changes a cloud account"],
|
|
16
|
+
[/\bcurl\b[^|;&]*\s(?:-X\s*(?:POST|PUT|PATCH|DELETE)|--request\s+(?:POST|PUT|PATCH|DELETE)|-d\b|--data\b|-F\b|--upload-file|-T\b)/i, "sends data to a server"],
|
|
17
|
+
[/\b(?:psql|mysql|sqlite3|mongo|mongosh|redis-cli)\b[^|;&]*(?:drop|delete|truncate|update|insert|alter|flushall|flushdb|set\b)/i, "writes to a database"],
|
|
18
|
+
[/\b(?:ssh|scp|rsync)\b/, "acts on another machine"], [/\bsendmail\b|\bmail\s+-s\b|\bslack\b[^|;&]*(?:post|send)/i, "sends a message"],
|
|
19
|
+
[/\brm\s+(?:-\S+\s+)*(?:\/|~|\$HOME|\.\.\/)/, "deletes outside the workspace"], [/(?:>|>>|\btee\b)\s*(?:\/(?!tmp\b|dev\/null\b)|~\/)/, "writes outside the workspace"],
|
|
20
|
+
];
|
|
21
|
+
export function irreversibleReason(cmd) { return IRREVERSIBLE.find(([re]) => re.test(cmd))?.[1]; }
|
|
22
|
+
export class Snapshots {
|
|
23
|
+
cwd;
|
|
24
|
+
ok;
|
|
25
|
+
gitDir = "";
|
|
26
|
+
constructor(cwd) { this.cwd = resolve(cwd); }
|
|
27
|
+
/** Snapshots need a git repository (with or without commits). */
|
|
28
|
+
async available() {
|
|
29
|
+
if (this.ok !== undefined)
|
|
30
|
+
return this.ok;
|
|
31
|
+
try {
|
|
32
|
+
this.gitDir = resolve(this.cwd, (await git(this.cwd, ["rev-parse", "--git-dir"])).trim());
|
|
33
|
+
this.ok = true;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
this.ok = false;
|
|
37
|
+
}
|
|
38
|
+
return this.ok;
|
|
39
|
+
}
|
|
40
|
+
index() { return join(this.gitDir, "amness-snapshot.index"); }
|
|
41
|
+
/** Record the working tree as a tree object and return its id (undefined when there is no git repository or git fails). */
|
|
42
|
+
async take() {
|
|
43
|
+
if (!(await this.available()))
|
|
44
|
+
return undefined;
|
|
45
|
+
try {
|
|
46
|
+
const env = { GIT_INDEX_FILE: this.index() };
|
|
47
|
+
await git(this.cwd, ["add", "-A", "--", ".", ...EXCLUDE], env);
|
|
48
|
+
return (await git(this.cwd, ["write-tree"], env)).trim();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Paths in the working tree that differ from the snapshot (changed, deleted, or created since). */
|
|
55
|
+
async changedSince(tree) {
|
|
56
|
+
const now = await this.take();
|
|
57
|
+
if (!now || now === tree)
|
|
58
|
+
return [];
|
|
59
|
+
const out = await git(this.cwd, ["diff-tree", "-r", "--name-only", "-z", tree, now]);
|
|
60
|
+
return out.split("\0").filter(Boolean);
|
|
61
|
+
}
|
|
62
|
+
/** Put the working tree back to `tree`: restore files that were changed or deleted, remove files that were created (only those git would see: ignored files are never touched). */
|
|
63
|
+
async restore(tree) {
|
|
64
|
+
if (!(await this.available()))
|
|
65
|
+
throw new Error("not a git repository");
|
|
66
|
+
const changed = await this.changedSince(tree), restored = [], removed = [];
|
|
67
|
+
if (!changed.length)
|
|
68
|
+
return { restored, removed };
|
|
69
|
+
const inTree = new Set((await git(this.cwd, ["ls-tree", "-r", "--name-only", "-z", tree])).split("\0").filter(Boolean));
|
|
70
|
+
const tmp = mkdtempSync(join(tmpdir(), "amness-restore-")), env = { GIT_INDEX_FILE: join(tmp, "index") };
|
|
71
|
+
try {
|
|
72
|
+
await git(this.cwd, ["read-tree", tree], env);
|
|
73
|
+
for (const p of changed) {
|
|
74
|
+
const abs = resolve(this.cwd, p);
|
|
75
|
+
if (abs !== this.cwd && !abs.startsWith(this.cwd + sep))
|
|
76
|
+
continue;
|
|
77
|
+
if (inTree.has(p)) {
|
|
78
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
79
|
+
await git(this.cwd, ["checkout-index", "-f", "--", p], env);
|
|
80
|
+
restored.push(p);
|
|
81
|
+
}
|
|
82
|
+
else if (existsSync(abs) && statSync(abs).isFile()) {
|
|
83
|
+
rmSync(abs);
|
|
84
|
+
removed.push(p);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
return { restored, removed };
|
|
92
|
+
}
|
|
93
|
+
}
|