From 6f1c1454e9dcf2b793224bdd21507427d25f2e30 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 03:25:11 +0000 Subject: [PATCH 1/6] fix(skills): resolve flat and SKILL.md layouts Load skills from the configured directory as name.md or name/SKILL.md, with a compatibility pass for a nested skills/ folder and aliases onto upstream skill names. Recognize both Anti-Rationalization and Common Rationalizations headings so those tables reach the executor prompt. Co-authored-by: telivity-otaip --- docs/automation-friction.md | 77 ++++++++++++++++ .../src/__tests__/skill-loader.test.ts | 87 ++++++++++++++++++ .../asil-improvement-loop/src/executor.ts | 38 +++----- packages/asil-improvement-loop/src/index.ts | 5 + .../asil-improvement-loop/src/skill-loader.ts | 91 +++++++++++++++++++ packages/asil-improvement-loop/src/types.ts | 4 + .../src/__tests__/skill-loader.test.ts | 35 +++++++ packages/asil-thought-multiplier/src/index.ts | 6 ++ .../src/skill-loader.ts | 62 +++++++++++++ .../src/thinkers/security.ts | 2 +- .../src/thinkers/shared.ts | 11 +-- .../src/thinkers/test-strategist.ts | 2 +- 12 files changed, 384 insertions(+), 36 deletions(-) create mode 100644 docs/automation-friction.md create mode 100644 packages/asil-improvement-loop/src/__tests__/skill-loader.test.ts create mode 100644 packages/asil-improvement-loop/src/skill-loader.ts create mode 100644 packages/asil-thought-multiplier/src/__tests__/skill-loader.test.ts create mode 100644 packages/asil-thought-multiplier/src/skill-loader.ts diff --git a/docs/automation-friction.md b/docs/automation-friction.md new file mode 100644 index 0000000..1d22cb5 --- /dev/null +++ b/docs/automation-friction.md @@ -0,0 +1,77 @@ +# Lessons from wiring agent skills into an autonomous loop + +Notes on what fought unattended execution, and where a skill boundary went fuzzy under load. Grounded in production use and in this repository. + +## 1. Personas assumed a human merge step + +Review personas written for a human in the loop over-reject when nothing is waiting to discuss the comment. + +- A code reviewer that audits the whole file treats pre-existing issues in unchanged lines as blockers. +- A security reviewer that treats a dead-code cleanup as an attack-surface change blocks the task. +- A test reviewer that demands new tests for a pure deletion blocks the task. + +The scoping rule that held up: review the diff only, leave pre-existing issues out of scope, and calibrate scrutiny to the risk of the change. The same preamble belongs on the adversarial gate. + +## 2. Section titles silently dropped the rationalization tables + +Some skill files title the table **Common Rationalizations**. The executor originally looked only for **Anti-Rationalization**. Under automation the tables were never injected, and the model fell through to a generic fallback. + +The loader accepts both headings. + +## 3. Skill path and name layout + +Two layouts show up in the wild: + +- `{skillsPath}/{name}.md` +- `{skillsPath}/{name}/SKILL.md` + +Older installs also nested another `skills/` directory under the configured path. Several category names do not match upstream folder names (`security-review` vs `security-and-hardening`, `test-coverage-improvement` vs `test-driven-development`). A missing file used to fail open into an inline fallback, so the loop ran while ignoring the skills that were installed. + +Resolution order for every name, then again for each alias: + +1. `{name}.md` +2. `{name}/SKILL.md` +3. `skills/{name}.md` +4. `skills/{name}/SKILL.md` + +The configured path defaults to `/.asil/skills`. + +## 4. Feature-work skills fought cleanup categories + +| Skill | Fought when | +|---|---| +| Test-driven development | Applied to dead-code or simplification work and insisted on a red-green cycle for a behavior-preserving change | +| Security hardening | Applied to a one-line visibility change and invented an auth concern | +| Incremental implementation | Asked for a thin slice when the scanner had queued a whole-file TODO cluster | + +Skills often describe how a person should build a feature. Several scanner categories are mechanical cleanup. The category-to-skill mapping needs an explicit "no behavior change" delta, not only a file load. + +## 5. A strict priority queue let one category monopolize a run + +Strict priority meant a noisy test suite could starve every other category for the whole run. Rotating through categories that have eligible work, while still taking the highest-priority task inside a category, fixed the starvation. + +## 6. Scanner false positives became confident bad edits + +A dead-code skill combined with a shallow "no in-repo references" check proposed deleting public API surface re-exported from package entry points. The skill did what it was asked. The task was wrong. Entry points are exempt, and the task text has to state uncertainty before the skill runs. + +## 7. What held up + +- Rationalization tables, once they were actually injected. +- Fail-closed JSON from personas and the adversarial gate (unparseable output is a rejection). +- Cost checkpoints around the review fan-out. +- Domain-question markers as the boundary where a skill must stop instead of inventing domain logic. + +## Name mapping + +| Category or thinker | Primary name | Alias | +|---|---|---| +| vulnerability | `security-review` | `security-and-hardening` | +| test-failure / coverage-gap | `test-coverage-improvement` | `test-driven-development` | +| test strategist | `test-driven-development` | `testing-strategy` | +| security thinker | `security-and-hardening` | `security-review` | +| dead-code | `dead-code-removal` | `code-simplification` | +| dependency-update | `dependency-update` | `deprecation-and-migration` | +| documentation | `documentation-generation` | `documentation-and-adrs` | +| todo-resolution | `todo-resolution` | `incremental-implementation`, `debugging-and-error-recovery` | +| type-error / complexity | `code-simplification` | exact match | +| planner / spec writer | `planning-and-task-breakdown` / `spec-driven-development` | exact match | diff --git a/packages/asil-improvement-loop/src/__tests__/skill-loader.test.ts b/packages/asil-improvement-loop/src/__tests__/skill-loader.test.ts new file mode 100644 index 0000000..10c5bc0 --- /dev/null +++ b/packages/asil-improvement-loop/src/__tests__/skill-loader.test.ts @@ -0,0 +1,87 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + extractAntiRationalization, + loadSkillMarkdown, + skillCandidatePaths, +} from '../skill-loader.js'; + +describe('skillCandidatePaths', () => { + it('tries the skill file directly under skillsPath before nested layouts and aliases', () => { + expect(skillCandidatePaths('dead-code-removal')).toEqual([ + 'dead-code-removal.md', + join('dead-code-removal', 'SKILL.md'), + join('skills', 'dead-code-removal.md'), + join('skills', 'dead-code-removal', 'SKILL.md'), + 'code-simplification.md', + join('code-simplification', 'SKILL.md'), + join('skills', 'code-simplification.md'), + join('skills', 'code-simplification', 'SKILL.md'), + ]); + }); +}); + +describe('loadSkillMarkdown', () => { + let dir: string; + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it('reads {name}.md directly under the skills directory', () => { + dir = mkdtempSync(join(tmpdir(), 'skills-')); + writeFileSync(join(dir, 'code-simplification.md'), '# direct\n'); + expect(loadSkillMarkdown(dir, 'type-error')).toBe(''); + expect(loadSkillMarkdown(dir, 'code-simplification')).toBe('# direct\n'); + }); + + it('reads {name}/SKILL.md', () => { + dir = mkdtempSync(join(tmpdir(), 'skills-')); + mkdirSync(join(dir, 'code-simplification')); + writeFileSync(join(dir, 'code-simplification', 'SKILL.md'), '# skillmd\n'); + expect(loadSkillMarkdown(dir, 'code-simplification')).toBe('# skillmd\n'); + }); + + it('falls back to a nested skills/ directory', () => { + dir = mkdtempSync(join(tmpdir(), 'skills-')); + mkdirSync(join(dir, 'skills')); + writeFileSync(join(dir, 'skills', 'code-simplification.md'), '# nested\n'); + expect(loadSkillMarkdown(dir, 'code-simplification')).toBe('# nested\n'); + }); + + it('resolves category aliases onto upstream skill names', () => { + dir = mkdtempSync(join(tmpdir(), 'skills-')); + mkdirSync(join(dir, 'security-and-hardening')); + writeFileSync( + join(dir, 'security-and-hardening', 'SKILL.md'), + '# hardening\n', + ); + expect(loadSkillMarkdown(dir, 'security-review')).toBe('# hardening\n'); + }); + + it('returns an empty string when nothing matches', () => { + dir = mkdtempSync(join(tmpdir(), 'skills-')); + expect(loadSkillMarkdown(dir, 'missing')).toBe(''); + }); +}); + +describe('extractAntiRationalization', () => { + it('extracts a Common Rationalizations section', () => { + const skill = [ + '# Skill', + '', + '## Common Rationalizations', + '- Skipping tests is fine', + '', + '## Next', + 'ignore', + ].join('\n'); + const section = extractAntiRationalization(skill); + expect(section).toMatch(/Common Rationalizations/); + expect(section).toMatch(/Skipping tests is fine/); + expect(section).not.toMatch(/ignore/); + }); + + it('returns an empty string when the section is absent', () => { + expect(extractAntiRationalization('# Skill\n\nNo table.')).toBe(''); + }); +}); diff --git a/packages/asil-improvement-loop/src/executor.ts b/packages/asil-improvement-loop/src/executor.ts index e8db27a..3c77d67 100644 --- a/packages/asil-improvement-loop/src/executor.ts +++ b/packages/asil-improvement-loop/src/executor.ts @@ -9,9 +9,20 @@ import type { LLMCaller, LLMResponse, } from './types.js'; +import { + extractAntiRationalization, + loadSkillMarkdown, +} from './skill-loader.js'; import { CATEGORY_SKILL_MAP } from './types.js'; import type { CommandRunner } from './scanner.js'; +export { extractAntiRationalization } from './skill-loader.js'; +export { + loadSkillMarkdown, + skillCandidatePaths, + SKILL_NAME_ALIASES, +} from './skill-loader.js'; + /** * Structured logger contract. Defaults to stderr via `console.error`, * but tests (and the CLI) can inject a spy/mock to capture events. @@ -467,11 +478,7 @@ async function defaultSkillLoader( skillsPath: string, skillName: string, ): Promise { - try { - return readFileSync(join(skillsPath, 'skills', `${skillName}.md`), 'utf8'); - } catch { - return ''; - } + return loadSkillMarkdown(skillsPath, skillName); } function defaultReadFile(absPath: string): string | null { @@ -482,27 +489,6 @@ function defaultReadFile(absPath: string): string | null { } } -/** Extracts the anti-rationalization table — a section commonly titled - * "Anti-Rationalization Table" in the configured skills. Returns the skill - * content unchanged if we can't locate it. */ -export function extractAntiRationalization(skill: string): string { - if (!skill) return ''; - const lines = skill.split(/\r?\n/); - const startIdx = lines.findIndex((l) => - /^#{1,6}\s*anti[- ]?rationalization/i.test(l), - ); - if (startIdx === -1) return ''; - const afterStart = lines.slice(startIdx); - const nextHeaderIdx = afterStart - .slice(1) - .findIndex((l) => /^#{1,6}\s+/.test(l)); - const slice = - nextHeaderIdx === -1 - ? afterStart - : afterStart.slice(0, nextHeaderIdx + 1); - return slice.join('\n').trim(); -} - export function buildExecutionPrompt( skillContent: string, antiRationalization: string, diff --git a/packages/asil-improvement-loop/src/index.ts b/packages/asil-improvement-loop/src/index.ts index 2d89465..580dfad 100644 --- a/packages/asil-improvement-loop/src/index.ts +++ b/packages/asil-improvement-loop/src/index.ts @@ -51,6 +51,11 @@ export { type FileBlock, type FileFetcher, } from './executor.js'; +export { + loadSkillMarkdown, + skillCandidatePaths, + SKILL_NAME_ALIASES, +} from './skill-loader.js'; export { selfReview } from './self-review.js'; export { adversarialReview, diff --git a/packages/asil-improvement-loop/src/skill-loader.ts b/packages/asil-improvement-loop/src/skill-loader.ts new file mode 100644 index 0000000..f1ab50a --- /dev/null +++ b/packages/asil-improvement-loop/src/skill-loader.ts @@ -0,0 +1,91 @@ +/** + * Resolve Markdown skill content from a skills directory. + * + * `skillsPath` is the directory ASIL is configured to read (default + * `/.asil/skills`). Candidates are tried in order, then the same + * order for each alias: + * + * 1. `{name}.md` + * 2. `{name}/SKILL.md` + * 3. `skills/{name}.md` (older installs that nested a skills/ folder) + * 4. `skills/{name}/SKILL.md` + */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * When a category name does not match an upstream skill folder, try these + * fallbacks after the primary name. + */ +export const SKILL_NAME_ALIASES: Readonly> = { + 'security-review': ['security-and-hardening'], + 'testing-strategy': ['test-driven-development'], + 'test-coverage-improvement': ['test-driven-development'], + 'dead-code-removal': ['code-simplification'], + 'dependency-update': ['deprecation-and-migration'], + 'documentation-generation': ['documentation-and-adrs'], + 'todo-resolution': [ + 'incremental-implementation', + 'debugging-and-error-recovery', + ], +}; + +function layoutsFor(name: string): string[] { + return [ + `${name}.md`, + join(name, 'SKILL.md'), + join('skills', `${name}.md`), + join('skills', name, 'SKILL.md'), + ]; +} + +/** Candidate paths relative to `skillsPath` for a skill name or `name.md`. */ +export function skillCandidatePaths(skillName: string): string[] { + const primary = skillName.replace(/\.md$/i, ''); + const names = [primary, ...(SKILL_NAME_ALIASES[primary] ?? [])]; + const paths: string[] = []; + for (const name of names) paths.push(...layoutsFor(name)); + return paths; +} + +/** + * Load skill Markdown from disk. Returns an empty string when nothing + * matches so callers can fall back to inline defaults. + */ +export function loadSkillMarkdown(skillsPath: string, skillName: string): string { + for (const rel of skillCandidatePaths(skillName)) { + try { + return readFileSync(join(skillsPath, rel), 'utf8'); + } catch { + // try the next candidate + } + } + return ''; +} + +/** + * Extract the anti-rationalization section from a skill. + * + * Upstream skill libraries title this "Common Rationalizations". Other + * copies use "Anti-Rationalization Table". Both are recognized so the + * table is injected into the executor prompt. + */ +export function extractAntiRationalization(skill: string): string { + if (!skill) return ''; + const lines = skill.split(/\r?\n/); + const startIdx = lines.findIndex((l) => + /^#{1,6}\s*(?:anti[- ]?rationalization|common\s+rationalizations)\b/i.test( + l, + ), + ); + if (startIdx === -1) return ''; + const afterStart = lines.slice(startIdx); + const nextHeaderIdx = afterStart + .slice(1) + .findIndex((l) => /^#{1,6}\s+/.test(l)); + const slice = + nextHeaderIdx === -1 + ? afterStart + : afterStart.slice(0, nextHeaderIdx + 1); + return slice.join('\n').trim(); +} diff --git a/packages/asil-improvement-loop/src/types.ts b/packages/asil-improvement-loop/src/types.ts index 74131a6..2914bec 100644 --- a/packages/asil-improvement-loop/src/types.ts +++ b/packages/asil-improvement-loop/src/types.ts @@ -26,6 +26,10 @@ export const CATEGORY_PRIORITY: Record = { documentation: 8, }; +/** + * Category → skill basename. `loadSkillMarkdown` also tries aliases and + * both `{name}.md` and `{name}/SKILL.md` under the configured skills path. + */ export const CATEGORY_SKILL_MAP: Record = { 'test-failure': 'test-coverage-improvement', 'type-error': 'code-simplification', diff --git a/packages/asil-thought-multiplier/src/__tests__/skill-loader.test.ts b/packages/asil-thought-multiplier/src/__tests__/skill-loader.test.ts new file mode 100644 index 0000000..77e0abd --- /dev/null +++ b/packages/asil-thought-multiplier/src/__tests__/skill-loader.test.ts @@ -0,0 +1,35 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { afterEach, describe, expect, it } from 'vitest'; +import { loadSkillMarkdown } from '../skill-loader.js'; +import { loadMarkdownSkill } from '../thinkers/shared.js'; + +describe('thought-multiplier skill loader', () => { + let dir: string; + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it('loads a flat file directly under the skills directory', () => { + dir = mkdtempSync(join(tmpdir(), 'think-skills-')); + writeFileSync(join(dir, 'security-and-hardening.md'), '# direct\n'); + expect(loadSkillMarkdown(dir, 'security-and-hardening.md')).toBe('# direct\n'); + expect(loadMarkdownSkill(dir, 'security-and-hardening.md', 'fallback')).toBe( + '# direct\n', + ); + }); + + it('loads SKILL.md and historical aliases', () => { + dir = mkdtempSync(join(tmpdir(), 'think-skills-')); + mkdirSync(join(dir, 'security-review')); + writeFileSync(join(dir, 'security-review', 'SKILL.md'), '# legacy\n'); + expect(loadSkillMarkdown(dir, 'security-and-hardening.md')).toBe('# legacy\n'); + }); + + it('returns the fallback when no file matches', () => { + dir = mkdtempSync(join(tmpdir(), 'think-skills-')); + expect(loadMarkdownSkill(dir, 'security-and-hardening.md', 'fallback')).toBe( + 'fallback', + ); + expect(loadMarkdownSkill(dir, undefined, 'fallback')).toBe('fallback'); + }); +}); diff --git a/packages/asil-thought-multiplier/src/index.ts b/packages/asil-thought-multiplier/src/index.ts index 6d850f1..643090d 100644 --- a/packages/asil-thought-multiplier/src/index.ts +++ b/packages/asil-thought-multiplier/src/index.ts @@ -22,6 +22,12 @@ export { generateDoNotChange, } from './brief-builder.js'; export { runPapa, DEFAULT_CONFIG, type PapaResult } from './papa.js'; +export { + loadSkillMarkdown, + skillCandidatePaths, + SKILL_NAME_ALIASES, +} from './skill-loader.js'; +export { loadMarkdownSkill } from './thinkers/shared.js'; export { runSpecWriter } from './thinkers/spec-writer.js'; export { runSecurity } from './thinkers/security.js'; export { runTestStrategist } from './thinkers/test-strategist.js'; diff --git a/packages/asil-thought-multiplier/src/skill-loader.ts b/packages/asil-thought-multiplier/src/skill-loader.ts new file mode 100644 index 0000000..fedcbab --- /dev/null +++ b/packages/asil-thought-multiplier/src/skill-loader.ts @@ -0,0 +1,62 @@ +/** + * Resolve Markdown skill content from a skills directory. + * + * `skillsPath` is the directory ASIL is configured to read (default + * `/.asil/skills`). See `skillCandidatePaths` for the layouts tried. + */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Historical thinker filenames that should still resolve when the installed + * skills use the other name. + */ +export const SKILL_NAME_ALIASES: Readonly> = { + 'security-review': ['security-and-hardening'], + 'security-and-hardening': ['security-review'], + 'testing-strategy': ['test-driven-development'], + 'test-driven-development': ['testing-strategy'], + 'planning-and-task-breakdown': [], + 'spec-driven-development': [], +}; + +function layoutsFor(name: string): string[] { + return [ + `${name}.md`, + join(name, 'SKILL.md'), + join('skills', `${name}.md`), + join('skills', name, 'SKILL.md'), + ]; +} + +/** Strip a trailing `.md` so callers can pass either `foo` or `foo.md`. */ +function normalizeSkillName(skillFile: string): string { + return skillFile.replace(/\.md$/i, ''); +} + +/** Candidate paths relative to `skillsPath` for a skill name. */ +export function skillCandidatePaths(skillFile: string): string[] { + const primary = normalizeSkillName(skillFile); + const names = [primary, ...(SKILL_NAME_ALIASES[primary] ?? [])]; + const paths: string[] = []; + for (const name of names) paths.push(...layoutsFor(name)); + return paths; +} + +/** + * Load skill Markdown from disk. Returns `null` when nothing matches so + * callers can fall back to inline defaults. + */ +export function loadSkillMarkdown( + skillsPath: string, + skillFile: string, +): string | null { + for (const rel of skillCandidatePaths(skillFile)) { + try { + return readFileSync(join(skillsPath, rel), 'utf8'); + } catch { + // try the next candidate + } + } + return null; +} diff --git a/packages/asil-thought-multiplier/src/thinkers/security.ts b/packages/asil-thought-multiplier/src/thinkers/security.ts index ff86bbc..6a3d0e2 100644 --- a/packages/asil-thought-multiplier/src/thinkers/security.ts +++ b/packages/asil-thought-multiplier/src/thinkers/security.ts @@ -19,7 +19,7 @@ export async function runSecurity( 'security', { label: 'Security Review', - skillFile: 'security-review.md', + skillFile: 'security-and-hardening.md', fallbackInstructions: FALLBACK, }, request, diff --git a/packages/asil-thought-multiplier/src/thinkers/shared.ts b/packages/asil-thought-multiplier/src/thinkers/shared.ts index e5706fc..d1d1ef9 100644 --- a/packages/asil-thought-multiplier/src/thinkers/shared.ts +++ b/packages/asil-thought-multiplier/src/thinkers/shared.ts @@ -5,8 +5,6 @@ * project rules, and asks the LLM to respond in a structured JSON * envelope we can parse back into ThinkerOutput. */ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; import type { Concern, LLMCaller, @@ -19,6 +17,7 @@ import type { Priority, Severity, } from '../types.js'; +import { loadSkillMarkdown } from '../skill-loader.js'; /** Default engineering rules injected into every thinker's system prompt. */ const PROJECT_RULES = ` @@ -69,7 +68,7 @@ const VALID_SEVERITIES: readonly Severity[] = ['blocker', 'warning', 'note']; export interface ThinkerPromptParts { /** Content-type heading used in the system prompt ("Spec Writer", etc.) */ label: string; - /** Filename of the Markdown skill (relative to markdownSkillsPath/skills). */ + /** Skill basename (`name` or `name.md`) resolved under markdownSkillsPath. */ skillFile?: string; /** Fallback instructions if the Markdown skill file can't be read. */ fallbackInstructions: string; @@ -81,11 +80,7 @@ export function loadMarkdownSkill( fallback: string, ): string { if (!skillFile) return fallback; - try { - return readFileSync(join(markdownSkillsPath, 'skills', skillFile), 'utf8'); - } catch { - return fallback; - } + return loadSkillMarkdown(markdownSkillsPath, skillFile) ?? fallback; } export function buildSystemPrompt( diff --git a/packages/asil-thought-multiplier/src/thinkers/test-strategist.ts b/packages/asil-thought-multiplier/src/thinkers/test-strategist.ts index 4a20510..4d345a0 100644 --- a/packages/asil-thought-multiplier/src/thinkers/test-strategist.ts +++ b/packages/asil-thought-multiplier/src/thinkers/test-strategist.ts @@ -19,7 +19,7 @@ export function runTestStrategist( 'test-strategist', { label: 'Test Strategist', - skillFile: 'testing-strategy.md', + skillFile: 'test-driven-development.md', fallbackInstructions: FALLBACK, }, request, From c374820858fcaf62cc23377eff96fff93e970e19 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 03:28:40 +0000 Subject: [PATCH 2/6] fix(runners): confine git operations and filesystem access Git and gh no longer inherit model API keys, and commits skip the target repository's hooks. Worktrees are namespaced per repository and deletion is limited to those directories. File reads, coverage paths, and relative data paths stay inside the repository. Pull request bodies and diff patches are written under the system temp directory. Co-authored-by: telivity-otaip --- .../src/__tests__/scanner.test.ts | 6 +- .../src/domain-questions.ts | Bin 9854 -> 9870 bytes packages/asil-improvement-loop/src/index.ts | 1 + packages/asil-improvement-loop/src/paths.ts | 22 +++ packages/asil-improvement-loop/src/scanner.ts | 26 +-- .../asil-runners/src/__tests__/wiring.test.ts | 123 ++++++++++++++- packages/asil-runners/src/run-a.ts | 22 ++- packages/asil-runners/src/run-b.ts | 7 +- packages/asil-runners/src/wiring.ts | 149 +++++++++++++++--- 9 files changed, 307 insertions(+), 49 deletions(-) create mode 100644 packages/asil-improvement-loop/src/paths.ts diff --git a/packages/asil-improvement-loop/src/__tests__/scanner.test.ts b/packages/asil-improvement-loop/src/__tests__/scanner.test.ts index 6b32390..e4721d0 100644 --- a/packages/asil-improvement-loop/src/__tests__/scanner.test.ts +++ b/packages/asil-improvement-loop/src/__tests__/scanner.test.ts @@ -54,8 +54,9 @@ describe('scanner', () => { expect(toRepoRelative('/repo', 'src/foo.ts')).toBe('src/foo.ts'); expect(toRepoRelative('/repo', './src/foo.ts')).toBe('src/foo.ts'); }); - it('leaves paths outside repoRoot unchanged', () => { - expect(toRepoRelative('/repo', '/other/x.ts')).toBe('/other/x.ts'); + it('drops paths outside repoRoot and parent traversals', () => { + expect(toRepoRelative('/repo', '/other/x.ts')).toBeNull(); + expect(toRepoRelative('/repo', '../etc/passwd')).toBeNull(); }); }); @@ -236,6 +237,7 @@ describe('scanner', () => { total: { branches: { pct: 90 } }, '/repo/src/foo.ts': { branches: { pct: 45 } }, '/repo/src/bar.ts': { branches: { pct: 85 } }, + '/other/outside.ts': { branches: { pct: 10 } }, }); const tasks = await scanCoverageGaps('/repo', { runner: mockRunner([]), diff --git a/packages/asil-improvement-loop/src/domain-questions.ts b/packages/asil-improvement-loop/src/domain-questions.ts index 5dc4fb0449085178bf2492af476aaf1fa6d1fa82..77280d9fb28688f737aa06c9cf8f1095395bc8b9 100644 GIT binary patch delta 93 zcmez8)91V41k2=$EHYZv3Pq{K`8j2&&iQ$1ndzlPsVM=8B^e5}3TZ|8xeDrf`UOC4 dv0hg3=Ep3d9856=n=N^-NQh$9HQ7$h0RW@rBL@Hg delta 86 zcmeD4{pYjc1Ph0bLQ!gQeooos<1Dh9C0M;V7#TLF@?McJOwP|s%SOSJzaqS3pQASV0X)o$ROP002Qq9yb60 diff --git a/packages/asil-improvement-loop/src/index.ts b/packages/asil-improvement-loop/src/index.ts index 580dfad..3364cd9 100644 --- a/packages/asil-improvement-loop/src/index.ts +++ b/packages/asil-improvement-loop/src/index.ts @@ -103,5 +103,6 @@ export type { TestFailure, TypeError as ScannerTypeError, } from './language-profile.js'; +export { isInsideRoot, resolveConfiguredPath } from './paths.js'; export { typescriptProfile } from './profiles/typescript.js'; export { pythonProfile } from './profiles/python.js'; diff --git a/packages/asil-improvement-loop/src/paths.ts b/packages/asil-improvement-loop/src/paths.ts new file mode 100644 index 0000000..fa0bf1c --- /dev/null +++ b/packages/asil-improvement-loop/src/paths.ts @@ -0,0 +1,22 @@ +/** + * Resolve operator-configured paths without letting a relative value + * escape the repository root. Absolute paths are kept: the operator + * named that location explicitly. + */ +import { isAbsolute, relative, resolve } from 'node:path'; + +export function isInsideRoot(root: string, candidate: string): boolean { + const rel = relative(resolve(root), resolve(candidate)); + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); +} + +export function resolveConfiguredPath(repoRoot: string, configured: string): string { + if (isAbsolute(configured)) return resolve(configured); + const abs = resolve(repoRoot, configured); + if (!isInsideRoot(repoRoot, abs)) { + throw new Error( + `Refusing path "${configured}": it resolves outside the repository root.`, + ); + } + return abs; +} diff --git a/packages/asil-improvement-loop/src/scanner.ts b/packages/asil-improvement-loop/src/scanner.ts index 9983739..a5c347d 100644 --- a/packages/asil-improvement-loop/src/scanner.ts +++ b/packages/asil-improvement-loop/src/scanner.ts @@ -1,5 +1,7 @@ import { createHash } from 'node:crypto'; +import { isAbsolute, relative, resolve, sep } from 'node:path'; import type { LanguageProfile } from './language-profile.js'; +import { isInsideRoot } from './paths.js'; import { typescriptProfile } from './profiles/typescript.js'; import type { ImprovementTask, Severity, TaskCategory } from './types.js'; @@ -230,6 +232,7 @@ export async function scanCoverageGaps( // Coverage reporters emit absolute file keys; normalize to // repo-relative so the executor doesn't reject the path. (#3) const filePath = toRepoRelative(repoRoot, entry.filePath); + if (!filePath) continue; tasks.push( makeTask({ category: 'coverage-gap', @@ -357,7 +360,7 @@ export function stableTaskId( category: TaskCategory, filePaths: readonly string[], ): string { - const key = [category, ...[...filePaths].map(normalizePath).sort()].join(''); + const key = [category, ...[...filePaths].map(normalizePath).sort()].join('\0'); const hash = createHash('sha1').update(key).digest('hex').slice(0, 12); return `${category}-${hash}`; } @@ -393,16 +396,19 @@ export function isEntryPointFile(p: string): boolean { /** * Convert an absolute path under `repoRoot` to a repo-relative path. - * Coverage tools (vitest's istanbul reporter, coverage.py) emit - * absolute file keys; left as-is they reach the executor as absolute - * `<<>>` paths, which buildPatchFromFiles rejects as - * suspicious. Paths already relative (or outside repoRoot) are returned - * unchanged. (Refs Codex review #3.) + * Coverage tools emit absolute file keys; left as-is they reach the + * executor as absolute `<<>>` paths, which the patch builder + * rejects. Paths outside the repository, and relative paths that climb + * out of it, are dropped (`null`) so they are never sent to a model. */ -export function toRepoRelative(repoRoot: string, p: string): string { - const root = repoRoot.endsWith('/') ? repoRoot : `${repoRoot}/`; - if (p.startsWith(root)) return p.slice(root.length); - return normalizePath(p); +export function toRepoRelative(repoRoot: string, p: string): string | null { + const normalized = normalizePath(p); + if (normalized.split(/[/\\]/).includes('..')) return null; + if (!isAbsolute(normalized)) return normalized; + const root = resolve(repoRoot); + const abs = resolve(normalized); + if (abs === root || !isInsideRoot(root, abs)) return null; + return relative(root, abs).split(sep).join('/'); } /** diff --git a/packages/asil-runners/src/__tests__/wiring.test.ts b/packages/asil-runners/src/__tests__/wiring.test.ts index b567203..7dbdc70 100644 --- a/packages/asil-runners/src/__tests__/wiring.test.ts +++ b/packages/asil-runners/src/__tests__/wiring.test.ts @@ -19,6 +19,7 @@ import { createFileFetcher, createFileReader, createGitOps, + gitOpsEnv, createOpenAICompatibleCaller, createOpenAICompatibleCodexCaller, loadEnv, @@ -475,6 +476,30 @@ describe('wiring', () => { }); }); + describe('gitOpsEnv', () => { + it('keeps git and GitHub credentials and drops model API keys', () => { + const out = gitOpsEnv({ + PATH: '/usr/bin', + HOME: '/home/x', + LANG: 'C', + GIT_AUTHOR_NAME: 'A', + GH_TOKEN: 'gho_token', + GITHUB_TOKEN: 'ghp_token', + ANTHROPIC_API_KEY: 'sk-ant-secret', + OPENAI_API_KEY: 'sk-oai-secret', + AWS_SECRET_ACCESS_KEY: 'aws-secret', + }); + expect(out.PATH).toBe('/usr/bin'); + expect(out.HOME).toBe('/home/x'); + expect(out.GIT_AUTHOR_NAME).toBe('A'); + expect(out.GH_TOKEN).toBe('gho_token'); + expect(out.GITHUB_TOKEN).toBe('ghp_token'); + expect(out.ANTHROPIC_API_KEY).toBeUndefined(); + expect(out.OPENAI_API_KEY).toBeUndefined(); + expect(out.AWS_SECRET_ACCESS_KEY).toBeUndefined(); + }); + }); + describe('createCommandRunner', () => { it('captures stdout and exitCode=0 for a successful command', async () => { const runner = createCommandRunner(); @@ -560,6 +585,15 @@ describe('wiring', () => { ); expect(content).toMatch(/createAnthropicCaller/); }); + + it('refuses absolute paths and paths that leave the repository', async () => { + const fetcher = createFileFetcher(); + const root = resolve(__dirname, '..', '..'); + await expect(fetcher.read('/etc/passwd', root)).rejects.toThrow(/outside the repository/); + await expect(fetcher.read('../../etc/passwd', root)).rejects.toThrow( + /outside the repository/, + ); + }); }); describe('createDiffApplier', () => { @@ -568,6 +602,18 @@ describe('wiring', () => { expect(typeof diff.apply).toBe('function'); expect(typeof diff.revert).toBe('function'); }); + + it('does not write the patch file into the working tree', async () => { + const dir = mkdtempSync(join(tmpdir(), 'asil-diff-')); + try { + const diff = createDiffApplier(); + await diff.apply('not a diff', dir); + expect(existsSync(join(dir, '.tmp-auto-diff.patch'))).toBe(false); + expect(existsSync(join(dir, '.tmp-pr-diff.patch'))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); describe('createGitOps', () => { @@ -597,7 +643,7 @@ describe('wiring', () => { }; const git = createGitOps('/repo', runner); const path = await git.createBranch('auto/test-failure/abc12345'); - expect(path).toMatch(/asil-auto-auto-test-failure-abc12345/); + expect(path).toMatch(/asil-auto-[0-9a-f]{12}-auto-test-failure-abc12345/); expect(calls.some((c) => c.args.includes('clone'))).toBe(false); }); @@ -751,7 +797,7 @@ describe('wiring', () => { }; const git = createGitOps('/repo', runner); const path = await git.createBranch('auto/test-failure/abc12345'); - expect(path).toMatch(/asil-auto-auto-test-failure-abc12345/); + expect(path).toMatch(/asil-auto-[0-9a-f]{12}-auto-test-failure-abc12345/); // Clone fallback actually ran. const cloneCall = calls.find((c) => c.args[0] === 'clone'); expect(cloneCall).toBeDefined(); @@ -793,6 +839,74 @@ describe('wiring', () => { /both 'git worktree add' and 'git clone' failed/, ); }); + + it('commits with hooks disabled', async () => { + const calls: string[][] = []; + const runner = { + async run(_cmd: string, args: string[]) { + calls.push(args); + if (args[0] === 'rev-parse') { + return { stdout: '/repo\n', stderr: '', exitCode: 0 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }; + const git = createGitOps('/repo', runner); + await git.commit('auto(type-error): fix', '/tmp/work'); + const commit = calls.find((args) => args.includes('commit')); + expect(commit).toEqual([ + '-c', + 'core.hooksPath=/dev/null', + 'commit', + '--no-verify', + '-m', + 'auto(type-error): fix', + ]); + }); + + it('writes the pull request body under the temp directory', async () => { + const calls: Array<{ cmd: string; args: string[] }> = []; + const runner = { + async run(cmd: string, args: string[]) { + calls.push({ cmd, args }); + if (args[0] === 'rev-parse') { + return { stdout: '/repo\n', stderr: '', exitCode: 0 }; + } + return { stdout: 'https://example.com/pull/1\n', stderr: '', exitCode: 0 }; + }, + }; + const git = createGitOps('/repo', runner); + await git.createPR({ + title: 't', + body: 'hello', + branch: 'auto/x/y', + base: 'main', + }); + const gh = calls.find((c) => c.cmd === 'gh'); + const bodyFile = gh?.args[gh.args.indexOf('--body-file') + 1] ?? ''; + expect(bodyFile.startsWith(tmpdir())).toBe(true); + expect(bodyFile.includes(`${join('repo', '.tmp-pr-body.md')}`)).toBe(false); + expect(existsSync(resolve('/repo', '.tmp-pr-body.md'))).toBe(false); + }); + + it('refuses to delete a path that is not this repository worktree', async () => { + const calls: Array<{ cmd: string; args: string[] }> = []; + const runner = { + async run(cmd: string, args: string[]) { + calls.push({ cmd, args }); + if (args[0] === 'rev-parse') { + return { stdout: '/repo\n', stderr: '', exitCode: 0 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }; + const git = createGitOps('/repo', runner); + await git.cleanup('/etc'); + expect(calls.some((c) => c.cmd === 'rm')).toBe(false); + expect(calls.some((c) => c.args[0] === 'worktree' && c.args[1] === 'remove')).toBe( + false, + ); + }); }); describe('createCostInfra', () => { @@ -827,6 +941,11 @@ describe('wiring', () => { expect(infra.dataDir).toBe(join(dir, 'custom', 'spend')); }); + it('rejects a relative ASIL_USAGE_DATA_DIR that leaves the repository', () => { + process.env.ASIL_USAGE_DATA_DIR = '../outside-repo'; + expect(() => createCostInfra(dir)).toThrow(/outside the repository/); + }); + it('honours ASIL_USAGE_DATA_DIR when set to an absolute path', () => { const abs = mkdtempSync(join(tmpdir(), 'asil-abs-')); process.env.ASIL_USAGE_DATA_DIR = abs; diff --git a/packages/asil-runners/src/run-a.ts b/packages/asil-runners/src/run-a.ts index 58c5371..2d461ad 100644 --- a/packages/asil-runners/src/run-a.ts +++ b/packages/asil-runners/src/run-a.ts @@ -7,7 +7,7 @@ import { execSync } from 'node:child_process'; import { mkdirSync } from 'node:fs'; import { createInterface } from 'node:readline/promises'; -import { isAbsolute, join, resolve } from 'node:path'; +import { join } from 'node:path'; import { EventSink, instrumentCodexCaller, @@ -21,6 +21,7 @@ import { createDomainAnswerStore, findDomainQuestions, pythonProfile, + resolveConfiguredPath, runLoop, scanCodebase, typescriptProfile, @@ -95,8 +96,7 @@ function parseFlags(argv: readonly string[]): Flags | null { } else if (a === '--skip-questions') { flags.skipQuestions = true; } else if (a === '--transcripts' && argv[i + 1]) { - const raw = argv[i + 1]!; - flags.transcriptsDir = isAbsolute(raw) ? raw : resolve(process.cwd(), raw); + flags.transcriptsDir = argv[i + 1]!; i += 1; } else if (a === '--profile' && argv[i + 1]) { const v = argv[i + 1]!; @@ -155,6 +155,9 @@ export async function main(): Promise { } const env = loadEnv(); + if (flags.transcriptsDir) { + flags.transcriptsDir = resolveConfiguredPath(env.REPO_ROOT, flags.transcriptsDir); + } // pnpm --filter changes cwd to the package dir (autonomous/runners/). // Resolve the actual git repo root so scanner paths, config paths, and @@ -298,12 +301,15 @@ export async function main(): Promise { maxTasksPerRun: flags.maxTasks, maxAttempts: 2, taskCooldownMs: 5000, - markdownSkillsPath: - process.env.ASIL_SKILLS_PATH ?? resolve(env.REPO_ROOT, '.asil', 'skills'), + markdownSkillsPath: resolveConfiguredPath( + env.REPO_ROOT, + process.env.ASIL_SKILLS_PATH ?? '.asil/skills', + ), repoRoot: env.REPO_ROOT, - queuePath: - process.env.ASIL_QUEUE_PATH ?? - resolve(env.REPO_ROOT, '.asil', 'usage-data', 'queue.json'), + queuePath: resolveConfiguredPath( + env.REPO_ROOT, + process.env.ASIL_QUEUE_PATH ?? '.asil/usage-data/queue.json', + ), skipCategories: flags.skipCategories, minSeverity: flags.minSeverity, dequeueMode: flags.roundRobin ? 'round-robin' : 'priority', diff --git a/packages/asil-runners/src/run-b.ts b/packages/asil-runners/src/run-b.ts index 80360f4..8d22c07 100644 --- a/packages/asil-runners/src/run-b.ts +++ b/packages/asil-runners/src/run-b.ts @@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto'; import { writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import { resolveConfiguredPath } from 'asil-improvement-loop'; import { DEFAULT_CONFIG, runPapa } from 'asil-thought-multiplier'; import type { UserRequest } from 'asil-thought-multiplier'; import { @@ -69,8 +70,10 @@ export async function main(): Promise { const config = { ...DEFAULT_CONFIG, - markdownSkillsPath: - process.env.ASIL_SKILLS_PATH ?? resolve(env.REPO_ROOT, '.asil', 'skills'), + markdownSkillsPath: resolveConfiguredPath( + env.REPO_ROOT, + process.env.ASIL_SKILLS_PATH ?? '.asil/skills', + ), }; const result = await runPapa(request, llm, checkpoint, config); diff --git a/packages/asil-runners/src/wiring.ts b/packages/asil-runners/src/wiring.ts index f90b786..bb5e96b 100644 --- a/packages/asil-runners/src/wiring.ts +++ b/packages/asil-runners/src/wiring.ts @@ -3,16 +3,18 @@ * real API-backed implementations. Pure glue: no domain logic here. */ import Anthropic from '@anthropic-ai/sdk'; +import { createHash, randomUUID } from 'node:crypto'; import { execFile } from 'node:child_process'; import { existsSync, mkdirSync, + mkdtempSync, readFileSync, unlinkSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; -import { isAbsolute, resolve } from 'node:path'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; import { promisify } from 'node:util'; import { BudgetManager, @@ -25,13 +27,14 @@ import type { LLMCaller, LLMResponse, } from 'asil-thought-multiplier'; -import type { - CodexCaller, - CommandRunner, - DiffApplier, - FileFetcher, - FileReader, - GitOperations, +import { + resolveConfiguredPath, + type CodexCaller, + type CommandRunner, + type DiffApplier, + type FileFetcher, + type FileReader, + type GitOperations, } from 'asil-improvement-loop'; const execFileAsync = promisify(execFile); @@ -363,6 +366,74 @@ export function scrubbedEnv( return out; } +/** + * Environment for git and gh. Target-repo hooks must not see model API + * keys. Only identity, temp, path, and the tokens those tools need to + * authenticate are copied. + */ +const GITOPS_ENV_EXACT = new Set([ + 'PATH', + 'HOME', + 'USER', + 'LOGNAME', + 'LANG', + 'TMPDIR', + 'TMP', + 'TEMP', + 'TERM', + 'GH_TOKEN', + 'GITHUB_TOKEN', + 'GH_HOST', + 'GH_ENTERPRISE_TOKEN', + 'SSH_AUTH_SOCK', + 'SSH_AGENT_PID', + 'GIT_EXEC_PATH', + 'GIT_SSH', + 'GIT_SSH_COMMAND', + 'GIT_ASKPASS', +]); + +export function gitOpsEnv( + source: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const out: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(source)) { + if (value === undefined) continue; + if ( + GITOPS_ENV_EXACT.has(key) || + key.startsWith('GIT_') || + key.startsWith('LC_') + ) { + out[key] = value; + } + } + return out; +} + +/** Short stable id so worktrees from different repositories cannot collide. */ +export function worktreeSlug(repoRoot: string): string { + return createHash('sha256').update(resolve(repoRoot)).digest('hex').slice(0, 12); +} + +export function worktreePathFor(repoRoot: string, branchName: string): string { + const safeName = branchName.replace(/[/\\]/g, '-'); + return resolve(tmpdir(), `asil-auto-${worktreeSlug(repoRoot)}-${safeName}`); +} + +/** True when `candidate` is the temp worktree directory for `repoRoot`. */ +export function isDeletableWorktree(workDir: string, repoRoot: string): boolean { + const abs = resolve(workDir); + const tmp = resolve(tmpdir()); + const prefix = resolve(tmpdir(), `asil-auto-${worktreeSlug(repoRoot)}-`); + const underTmp = abs === tmp || abs.startsWith(tmp + sep); + return underTmp && abs.startsWith(prefix); +} + +function isInsideRoot(root: string, candidate: string): boolean { + const rel = relative(resolve(root), resolve(candidate)); + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); +} + /** Real CommandRunner via execFile. Captures stdout, stderr, and exit code * without throwing — callers inspect exitCode to decide. * Pass `{ env: scrubbedEnv() }` for the untrusted target-repo runner so @@ -435,7 +506,14 @@ export function createFileReader(): FileReader { export function createFileFetcher(): FileFetcher { return { async read(filePath, repoRoot) { - const abs = resolve(repoRoot, filePath); + if (isAbsolute(filePath) || filePath.split(/[/\\]/).includes('..')) { + throw new Error(`Refusing to read a path outside the repository: ${filePath}`); + } + const root = resolve(repoRoot); + const abs = resolve(root, filePath); + if (!isInsideRoot(root, abs)) { + throw new Error(`Refusing to read a path outside the repository: ${filePath}`); + } return readFileSync(abs, 'utf8'); }, }; @@ -445,10 +523,12 @@ export function createFileFetcher(): FileFetcher { * unified diff. On success the tree stays dirty — the PR builder is * responsible for checking out a fresh branch and re-applying cleanly. */ export function createDiffApplier(): DiffApplier { - const runner = createCommandRunner(); + const runner = createCommandRunner({ env: scrubbedEnv() }); + const patchFile = (): string => + join(tmpdir(), `asil-diff-${randomUUID()}.patch`); return { async apply(diff, repoRoot) { - const tmpFile = resolve(repoRoot, '.tmp-auto-diff.patch'); + const tmpFile = patchFile(); writeFileSync(tmpFile, diff, 'utf8'); try { const check = await runner.run('git', ['apply', '--check', tmpFile], { @@ -469,7 +549,7 @@ export function createDiffApplier(): DiffApplier { } }, async revert(diff, repoRoot) { - const tmpFile = resolve(repoRoot, '.tmp-auto-revert.patch'); + const tmpFile = patchFile(); writeFileSync(tmpFile, diff, 'utf8'); try { await runner.run('git', ['apply', '--reverse', tmpFile], { @@ -501,7 +581,7 @@ export function createGitOps( seedPath: string, injectedRunner?: CommandRunner, ): GitOperations { - const runner = injectedRunner ?? createCommandRunner(); + const runner = injectedRunner ?? createCommandRunner({ env: gitOpsEnv() }); // `seedPath` is wherever the CLI was invoked from — it may be a // subdirectory (e.g. `autonomous/runners/`) rather than the repo @@ -545,8 +625,7 @@ export function createGitOps( // Each branch name maps 1:1 to a worktree path so concurrent // tasks don't collide. Slashes in the branch name (auto//) // can't appear in a directory name — swap for dashes. - const safeName = name.replace(/\//g, '-'); - const worktreePath = resolve(tmpdir(), `asil-auto-${safeName}`); + const worktreePath = worktreePathFor(repoRoot, name); // Clear the four possible leftover states from a prior crashed run: // 1. Worktree admin record still registered in .git/worktrees/. @@ -561,6 +640,11 @@ export function createGitOps( }); await runner.run('git', ['worktree', 'prune'], { cwd: repoRoot }); if (existsSync(worktreePath)) { + if (!isDeletableWorktree(worktreePath, repoRoot)) { + throw new Error( + `Refusing to delete ${worktreePath}: not an ASIL worktree for this repository.`, + ); + } await runner.run('rm', ['-rf', worktreePath], { cwd: tmpdir() }); } // `git branch -D` is the fix for "fatal: a branch named '' @@ -672,7 +756,16 @@ export function createGitOps( async commit(message, workDir) { await mustRunIn(workDir, ['add', '-A']); - await mustRunIn(workDir, ['commit', '-m', message]); + // Disable the target repository's hooks. They run with this + // process's environment and can touch other checkouts. + await mustRunIn(workDir, [ + '-c', + 'core.hooksPath=/dev/null', + 'commit', + '--no-verify', + '-m', + message, + ]); }, async push(branch, workDir) { @@ -702,7 +795,8 @@ export function createGitOps( // `gh pr create` talks to GitHub's API; cwd just needs to be a // checkout of the same repo. const repoRoot = await gitRoot(); - const bodyFile = resolve(repoRoot, '.tmp-pr-body.md'); + const bodyDir = mkdtempSync(join(tmpdir(), 'asil-pr-')); + const bodyFile = join(bodyDir, 'body.md'); writeFileSync(bodyFile, body, 'utf8'); try { const { stdout, stderr, exitCode } = await runner.run( @@ -743,16 +837,21 @@ export function createGitOps( }, async cleanup(workDir) { - // Best-effort — never throw from cleanup. First try the worktree - // teardown (works when we used `git worktree add`, harmlessly - // errors when we used the clone fallback). Then nuke the dir to - // catch the clone case OR any worktree residue. + // Best-effort — never throw from cleanup. Deletion is limited to + // the temp worktree directory for this repository. const repoRoot = await gitRoot(); - await runner.run('git', ['worktree', 'remove', workDir, '--force'], { + if (!isDeletableWorktree(workDir, repoRoot)) { + console.error( + `[git] Refusing to delete ${workDir}: not an ASIL worktree for this repository.`, + ); + return; + } + const safe = resolve(workDir); + await runner.run('git', ['worktree', 'remove', safe, '--force'], { cwd: repoRoot, }); - if (existsSync(workDir)) { - await runner.run('rm', ['-rf', workDir], { cwd: tmpdir() }); + if (existsSync(safe)) { + await runner.run('rm', ['-rf', safe], { cwd: tmpdir() }); } }, }; @@ -778,7 +877,7 @@ const DEFAULT_USAGE_DATA_SUBPATH = '.asil/usage-data'; * factory for CostCheckpoints. Creates the usage-data dir if missing. */ export function createCostInfra(repoRoot: string): CostInfra { const configured = process.env.ASIL_USAGE_DATA_DIR ?? DEFAULT_USAGE_DATA_SUBPATH; - const dataDir = isAbsolute(configured) ? configured : resolve(repoRoot, configured); + const dataDir = resolveConfiguredPath(repoRoot, configured); mkdirSync(dataDir, { recursive: true }); const tracker = new TokenTracker(resolve(dataDir, 'usage.json')); From c76597f0323d42d3cc4c02e19b6b79c311746013 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 03:29:46 +0000 Subject: [PATCH 3/6] feat(cli): require --apply before opening pull requests grind still runs the review gates and commits on a local auto/ branch. Push and pull request creation happen only when --apply is passed. Programmatic runLoop callers keep the previous publish behavior unless openPullRequests is set to false. Co-authored-by: telivity-otaip --- .../src/__tests__/loop.test.ts | 50 +++++++++++++++ packages/asil-improvement-loop/src/loop.ts | 61 ++++++++++++++++--- packages/asil-improvement-loop/src/types.ts | 8 +++ .../src/__tests__/run-a-flags.test.ts | 22 +++++++ packages/asil-runners/src/cli.ts | 9 ++- packages/asil-runners/src/run-a.ts | 33 +++++++++- 6 files changed, 167 insertions(+), 16 deletions(-) create mode 100644 packages/asil-runners/src/__tests__/run-a-flags.test.ts diff --git a/packages/asil-improvement-loop/src/__tests__/loop.test.ts b/packages/asil-improvement-loop/src/__tests__/loop.test.ts index 27a9197..056f5c5 100644 --- a/packages/asil-improvement-loop/src/__tests__/loop.test.ts +++ b/packages/asil-improvement-loop/src/__tests__/loop.test.ts @@ -149,6 +149,56 @@ describe('runLoop — integration', () => { expect(result.outcomes[0]?.totalTokenUsage.outputTokens).toBeGreaterThan(100); }); + it('does not push or open a pull request when openPullRequests is false', async () => { + const queue = new TaskQueue(queuePath); + queue.enqueue(mkTask({ id: 't-local' })); + + const git = mockGit('https://example.com/pr/9'); + const result = await runLoop(cfg({ openPullRequests: false }), { + llm: goodLLM(), + codex: mockCodex(JSON.stringify({ approved: true, severity: 'pass' })), + git, + tracker, + budgetManager, + runner: goodRunner(), + fileReader: mockFileReader(), + fileFetcher: mockFileFetcher(), + diff: mockDiffApplier(), + readCurrent: fakeReadCurrent, + queue, + }); + + expect(result.prsOpened).toBe(0); + expect(result.outcomes[0]?.status).toBe('committed-local'); + expect(git.operations.some((op) => op.startsWith('commit:'))).toBe(true); + expect(git.operations.some((op) => op.startsWith('push:'))).toBe(false); + expect(git.operations.some((op) => op.startsWith('createPR:'))).toBe(false); + }); + + it('pushes and opens a pull request when openPullRequests is true', async () => { + const queue = new TaskQueue(queuePath); + queue.enqueue(mkTask({ id: 't-publish' })); + + const git = mockGit('https://example.com/pr/10'); + const result = await runLoop(cfg({ openPullRequests: true }), { + llm: goodLLM(), + codex: mockCodex(JSON.stringify({ approved: true, severity: 'pass' })), + git, + tracker, + budgetManager, + runner: goodRunner(), + fileReader: mockFileReader(), + fileFetcher: mockFileFetcher(), + diff: mockDiffApplier(), + readCurrent: fakeReadCurrent, + queue, + }); + + expect(result.outcomes[0]?.status).toBe('pr-opened'); + expect(git.operations.some((op) => op.startsWith('push:'))).toBe(true); + expect(git.operations.some((op) => op.startsWith('createPR:'))).toBe(true); + }); + it('installs target-repo deps with --ignore-scripts by default (Codex #1 — no lifecycle-script RCE)', async () => { const queue = new TaskQueue(queuePath); queue.enqueue(mkTask({ id: 't-ignore-scripts' })); diff --git a/packages/asil-improvement-loop/src/loop.ts b/packages/asil-improvement-loop/src/loop.ts index 9a67314..403c6c5 100644 --- a/packages/asil-improvement-loop/src/loop.ts +++ b/packages/asil-improvement-loop/src/loop.ts @@ -457,15 +457,56 @@ export async function runLoop( break; } - // 7. All gates passed — commit + push from the worktree + open PR. - const outcome = await buildAndOpenPR( - task, - execution, - review, - adversarial, - deps.git, - workDir, - ); + // 7. All gates passed. Publish only when the caller opted in. + // Otherwise commit on the local auto/ branch and leave the push + // and pull request for a later run with --apply. + let outcome: TaskOutcome; + if (config.openPullRequests === false) { + const branch = branchNameFor(task); + const commitMsg = `auto(${task.category}): ${task.title}`; + try { + await deps.git.commit(commitMsg, workDir); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + console.error( + `\n[loop] commit failed for task ${task.id} on ${branch}: ${reason}`, + ); + checkpoint.complete(); + queue.complete(task.id, 'failed', `commit failed: ${reason}`); + outcomes.push({ + taskId: task.id, + status: 'execution-failed', + selfReview: review, + adversarialReview: adversarial, + totalTokenUsage: { ...taskTokens }, + completedAt: new Date(), + prBranch: branch, + failureReason: `commit failed: ${reason}`, + }); + continue; + } + console.error( + `[loop] Gates passed for ${task.id}. Committed locally on ${branch}. Re-run with --apply to push and open a pull request.`, + ); + outcome = { + taskId: task.id, + status: 'committed-local', + selfReview: review, + adversarialReview: adversarial, + totalTokenUsage: { ...taskTokens }, + completedAt: new Date(), + prBranch: branch, + }; + } else { + outcome = await buildAndOpenPR( + task, + execution, + review, + adversarial, + deps.git, + workDir, + ); + } checkpoint.complete(); // buildAndOpenPR reports execution-only token usage; replace it @@ -474,7 +515,7 @@ export async function runLoop( // spend (Codex review #2). outcome.totalTokenUsage = { ...taskTokens }; - if (outcome.status === 'pr-opened') { + if (outcome.status === 'pr-opened' || outcome.status === 'committed-local') { // Only mark completed when the PR actually opened. A silent // failure in commit/push/create used to be mis-marked completed. queue.complete(task.id, 'completed'); diff --git a/packages/asil-improvement-loop/src/types.ts b/packages/asil-improvement-loop/src/types.ts index 2914bec..bd02f20 100644 --- a/packages/asil-improvement-loop/src/types.ts +++ b/packages/asil-improvement-loop/src/types.ts @@ -166,6 +166,7 @@ export interface AdversarialReviewResult { export type TaskOutcomeStatus = | 'pr-opened' + | 'committed-local' | 'rejected-self-review' | 'rejected-adversarial' | 'execution-failed' @@ -215,6 +216,13 @@ export interface ImprovementLoopConfig { model: string; }; canaryGate?: CanaryGateConfig; + /** + * When false, a task that passes every gate is committed on a local + * `auto/...` branch and is not pushed or opened as a pull request. + * Defaults to true so existing programmatic callers keep publishing. + * The CLI sets this from `--apply`. + */ + openPullRequests?: boolean; } /** Shared LLM caller contract — same shape as System B uses. */ diff --git a/packages/asil-runners/src/__tests__/run-a-flags.test.ts b/packages/asil-runners/src/__tests__/run-a-flags.test.ts new file mode 100644 index 0000000..e8895ea --- /dev/null +++ b/packages/asil-runners/src/__tests__/run-a-flags.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { parseFlags } from '../run-a.js'; + +describe('parseFlags', () => { + it('does not publish unless --apply is set', () => { + const flags = parseFlags(['--max-tasks', '1']); + expect(flags?.apply).toBe(false); + expect(flags?.dryRun).toBe(false); + }); + + it('sets apply when --apply is passed', () => { + const flags = parseFlags(['--apply', '--max-tasks', '2']); + expect(flags?.apply).toBe(true); + expect(flags?.maxTasks).toBe(2); + }); + + it('keeps --dry-run from executing even if --apply is also passed', () => { + const flags = parseFlags(['--dry-run', '--apply']); + expect(flags?.dryRun).toBe(true); + expect(flags?.apply).toBe(true); + }); +}); diff --git a/packages/asil-runners/src/cli.ts b/packages/asil-runners/src/cli.ts index 1fbcb56..7f1aba3 100644 --- a/packages/asil-runners/src/cli.ts +++ b/packages/asil-runners/src/cli.ts @@ -34,7 +34,7 @@ const HELP = ` Examples: pnpm auto think "add rate limiting to the connect API endpoints" pnpm auto grind --dry-run - pnpm auto grind --max-tasks 3 + pnpm auto grind --max-tasks 3 --apply pnpm auto grind --skip dependency-update,documentation pnpm auto grind --skip-questions pnpm auto questions @@ -43,8 +43,11 @@ const HELP = ` pnpm auto status Environment: - Set ANTHROPIC_API_KEY and OPENAI_API_KEY in autonomous/.env - OPENAI_API_KEY only needed for grind (adversarial gate) + Set ANTHROPIC_API_KEY and OPENAI_API_KEY in the environment or in + the repository root .env (next to pnpm-workspace.yaml). + OPENAI_API_KEY is only needed for grind (adversarial gate). + grind commits locally unless you pass --apply, which pushes and + opens a pull request. `.trimStart(); export async function runCli( diff --git a/packages/asil-runners/src/run-a.ts b/packages/asil-runners/src/run-a.ts index 2d461ad..04e41f5 100644 --- a/packages/asil-runners/src/run-a.ts +++ b/packages/asil-runners/src/run-a.ts @@ -51,6 +51,8 @@ interface Flags { maxTasks: number; skipCategories: TaskCategory[]; dryRun: boolean; + /** Push the branch and open a pull request. Off unless --apply is passed. */ + apply: boolean; skipQuestions: boolean; /** Directory to write per-task transcripts + findings.md. When set, the * LLM/Codex callers and budget manager are wrapped to capture every @@ -70,11 +72,12 @@ function resolveProfile(name: 'ts' | 'python'): LanguageProfile { return name === 'python' ? pythonProfile : typescriptProfile; } -function parseFlags(argv: readonly string[]): Flags | null { +export function parseFlags(argv: readonly string[]): Flags | null { const flags: Flags = { maxTasks: 5, skipCategories: [], dryRun: false, + apply: false, skipQuestions: false, transcriptsDir: null, profile: 'ts', @@ -93,6 +96,8 @@ function parseFlags(argv: readonly string[]): Flags | null { i += 1; } else if (a === '--dry-run') { flags.dryRun = true; + } else if (a === '--apply') { + flags.apply = true; } else if (a === '--skip-questions') { flags.skipQuestions = true; } else if (a === '--transcripts' && argv[i + 1]) { @@ -131,6 +136,9 @@ Options: --max-tasks N Max tasks per run (default: 5) --skip cat1,cat2 Skip categories (e.g. dependency-update,documentation) --dry-run Scan and report tasks without executing + --apply Push passing changes and open a pull request. + Without this flag the loop still runs the gates and + commits on a local auto/ branch, but does not push. --skip-questions Bypass interactive triage of DOMAIN_QUESTION markers (still excludes their files from the run) --transcripts DIR Capture per-task LLM transcripts to DIR and run the @@ -194,7 +202,10 @@ export async function main(): Promise { ); console.log(` Min severity: ${flags.minSeverity}`); console.log(` Order: ${flags.roundRobin ? 'round-robin' : 'priority'}`); - console.log(` Dry run: ${flags.dryRun}\n`); + console.log(` Dry run: ${flags.dryRun}`); + console.log( + ` Publish: ${flags.apply ? 'push and open a pull request' : 'local commit only (pass --apply to publish)'}\n`, + ); const profile = resolveProfile(flags.profile); @@ -313,6 +324,7 @@ export async function main(): Promise { skipCategories: flags.skipCategories, minSeverity: flags.minSeverity, dequeueMode: flags.roundRobin ? 'round-robin' : 'priority', + openPullRequests: flags.apply, codexConfig: { apiKey: 'OPENAI_API_KEY', model: 'gpt-4o', @@ -415,6 +427,16 @@ export async function main(): Promise { console.log(` Budget exhausted: ${result.budgetExhausted}`); console.log(` Cycles detected: ${result.cyclesDetected}`); + const held = result.outcomes.filter((o) => o.status === 'committed-local'); + if (held.length > 0) { + console.log('\nLocal commits (not pushed):'); + for (const outcome of held) { + console.log( + ` ${outcome.prBranch ?? outcome.taskId} — re-run with --apply to push and open a pull request`, + ); + } + } + const prs = result.outcomes.filter((o) => o.status === 'pr-opened'); if (prs.length > 0) { console.log('\n🎉 PRs opened:'); @@ -445,7 +467,12 @@ export async function main(): Promise { // failed rather than just a count. The verbose executor + loop // logging prints the full error at the moment it happens; this is // the end-of-run summary view. - const failed = result.outcomes.filter((o) => o.status !== 'pr-opened' && o.status !== 'cycle-skipped'); + const failed = result.outcomes.filter( + (o) => + o.status !== 'pr-opened' && + o.status !== 'committed-local' && + o.status !== 'cycle-skipped', + ); if (failed.length > 0) { console.log('\n⚠️ Failure detail:'); const { TaskQueue } = await import('asil-improvement-loop'); From 027e7cdabf9008734c9f7da881a99419f19e7a86 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 03:31:22 +0000 Subject: [PATCH 4/6] docs: match the published API and repository location Load .env from the workspace root, document runLoop(config, deps), and point package metadata at TelivityAI/asil. Describe --apply, the skill file layout, and the fact that a dry run still executes scanner commands. Usage reports are Markdown only. Co-authored-by: telivity-otaip --- .gitignore | 2 +- README.md | 81 +++++++++++-------- examples/quickstart.md | 34 ++++---- packages/asil-analyzer/package.json | 2 +- packages/asil-cost-controller/README.md | 4 +- packages/asil-cost-controller/package.json | 2 +- packages/asil-improvement-loop/README.md | 4 +- packages/asil-improvement-loop/package.json | 2 +- .../src/__tests__/scanner.test.ts | 2 +- .../asil-runners/src/__tests__/cli.test.ts | 22 ++++- packages/asil-runners/src/cli.ts | 28 +++++-- packages/asil-thought-multiplier/README.md | 4 +- packages/asil-thought-multiplier/package.json | 2 +- 13 files changed, 121 insertions(+), 68 deletions(-) diff --git a/.gitignore b/.gitignore index 0699da8..23a0eaa 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,6 @@ dist/ *.log coverage/ -# Internal/private review + design notes — kept in private KB, never in the public repo +# Local review notes. Do not commit. CODEX_REVIEW.md docs/design/ diff --git a/README.md b/README.md index 8198451..4450ae1 100644 --- a/README.md +++ b/README.md @@ -45,14 +45,14 @@ Token budget governor. The substrate every other system runs on. - `TokenTracker` persists actual spend to disk - `CostCheckpoint` is the runtime API: every LLM call passes through it, and it can refuse the call when budget is exhausted - `KillSwitch` enforces hard daily caps -- `UsageReporter` formats spend rollups for inbox / Slack delivery +- `UsageReporter` formats spend rollups as Markdown - `decimal.js` for every cost calculation — no float drift on currency ### System B — Thought Multiplier Strategic thinking layer. Use this when one model giving one answer isn't good enough. -- A **router** decides which specialized thinkers a request needs (architecture, planning, test strategy, security, API design, spec writing) +- A **router** decides which specialized thinkers a request needs (planning, test strategy, security, API design, spec writing) - Each **thinker** runs independently with its own system prompt and JSON output envelope - A **synthesizer** merges their recommendations, surfaces conflicts, and computes a confidence score - The **papa agent** (Opus-tier) resolves conflicts and produces a final handoff brief @@ -95,7 +95,14 @@ ASIL executes target-repo code (`pnpm install`, build, tests) inside disposable - **Secrets are stripped from the execution environment.** The runner that executes target-repo commands gets a scrubbed env — `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and git/GitHub tokens are never in scope, so a malicious `postinstall`/build/test script can't exfiltrate them. The git/`gh` operations that genuinely need a token use a separate, full-env runner (credential separation). - **Install lifecycle scripts are disabled by default** (`pnpm install --ignore-scripts`). Opt back in with `ASIL_ALLOW_INSTALL_SCRIPTS=1` only for repos you trust. -This is process-level hardening (Level 1). It removes the credential-exfiltration vector but does **not** sandbox the filesystem or network — a hostile repo's build/test can still read local files or make network calls. For genuinely adversarial input, run ASIL inside a container with `--network=none` (containerized exec is a planned opt-in). See the design notes in the private KB. +This is process-level hardening. It removes the credential-exfiltration vector for install, build, and test, and it adds the following limits: + +- Git and `gh` do not inherit model API keys. Commits set `core.hooksPath` to `/dev/null` and pass `--no-verify`, so hooks in the target repository do not run. +- Worktrees live under the system temp directory and include a hash of the repository path, so two repositories cannot delete each other's worktrees. Cleanup refuses to delete anything else. +- Patch file reads stay inside the target repository. Relative paths configured for usage data, the queue, skills, and domain answers cannot climb out of the repository. Absolute paths are used as given. +- `auto grind` commits on a local `auto/` branch. Pass `--apply` to push that branch and open a pull request. + +It does **not** sandbox the filesystem or network. A hostile repository's build or test can still read local files or make network calls. For an untrusted repository, run ASIL inside a container that does not share your home directory or network. ### Languages — Python profile @@ -120,7 +127,7 @@ pnpm --filter asil-runners run:b "add rate limiting to the connect API endpoint" # 4. Run the autonomous loop (System A) pnpm --filter asil-runners auto grind --dry-run -pnpm --filter asil-runners auto grind --max-tasks 3 +pnpm --filter asil-runners auto grind --max-tasks 3 --apply ``` See [`examples/quickstart.md`](examples/quickstart.md) for a five-minute integration walkthrough. @@ -128,7 +135,7 @@ See [`examples/quickstart.md`](examples/quickstart.md) for a five-minute integra ## What it looks like ``` -$ pnpm --filter asil-runners auto grind --max-tasks 5 +$ pnpm --filter asil-runners auto grind --max-tasks 5 --apply 🤖 ASIL — Autonomous Improvement Loop Repo: /workspace/your-app @@ -211,10 +218,10 @@ Every LLM call in B and A flows through a `CostCheckpoint`. Each checkpoint is b |---|---|---|---| | `ANTHROPIC_API_KEY` | env | required | Primary LLM | | `OPENAI_API_KEY` | env | required | Adversarial gate (different provider) | -| `REPO_ROOT` | env | required | Absolute path to the target repo | -| `ASIL_USAGE_DATA_DIR` | env | `/.asil/usage-data` | Cost tracker state | -| `ASIL_QUEUE_PATH` | env | `/.asil/usage-data/queue.json` | Task queue | -| `ASIL_SKILLS_PATH` | env | `/.asil/skills` | Markdown skills the thinkers load | +| `REPO_ROOT` | env | current working directory | Absolute path to the target repo. Set this explicitly. | +| `ASIL_USAGE_DATA_DIR` | env | `/.asil/usage-data` | Cost tracker state. Relative values must stay inside the repo. | +| `ASIL_QUEUE_PATH` | env | `/.asil/usage-data/queue.json` | Task queue. Relative values must stay inside the repo. | +| `ASIL_SKILLS_PATH` | env | `/.asil/skills` | Markdown skills. Files are `{name}.md` or `{name}/SKILL.md` directly under this directory. | | `ASIL_DOMAIN_ANSWERS_PATH` | env | `/.asil/domain-answers.json` | Resolved domain answers | | `papaModel` / `thinkerModel` | `ThoughtMultiplierConfig` | `opus` / `sonnet` | Model tiers per role | | `executionModel` / `reviewModel` | `ImprovementLoopConfig` | `sonnet` / `sonnet` | Model tiers in System A | @@ -230,49 +237,57 @@ Every LLM call in B and A flows through a `CostCheckpoint`. Each checkpoint is b ## Integration -ASIL is a TypeScript monorepo. Add the four packages to your workspace and wire them up via the `LoopDeps` and `LLMCaller` interfaces. +ASIL is a TypeScript monorepo. The four library packages are not published to npm yet — vendor this repository and add `asil/packages/*` to your workspace. `asil-runners` is a private reference CLI. Import its wiring from the vendored source (`packages/asil-runners/src/wiring.ts`); it has no package entry point. + +`runLoop` takes the config first and the dependencies second: ```ts import { runLoop } from 'asil-improvement-loop'; import { createAnthropicCaller, createCodexCaller, - createGitOps, + createCommandRunner, createCostInfra, createDiffApplier, createFileFetcher, -} from 'asil-runners'; - -const llm = createAnthropicCaller(process.env.ANTHROPIC_API_KEY!); -const codex = createCodexCaller(process.env.OPENAI_API_KEY!); -const git = createGitOps(process.env.REPO_ROOT!); -const cost = createCostInfra(process.env.REPO_ROOT!); -const diff = createDiffApplier(); -const files = createFileFetcher(); - -await runLoop({ - llm, - codex, - git, - diff, - fileFetcher: files, - costInfra: cost, - config: { + createFileReader, + createGitOps, + scrubbedEnv, +} from './wiring.js'; + +const repoRoot = process.env.REPO_ROOT!; +const cost = createCostInfra(repoRoot); + +await runLoop( + { executionModel: 'sonnet', reviewModel: 'sonnet', maxTasksPerRun: 5, maxAttempts: 2, taskCooldownMs: 5000, - markdownSkillsPath: '.asil/skills', - repoRoot: process.env.REPO_ROOT!, - queuePath: '.asil/usage-data/queue.json', + markdownSkillsPath: `${repoRoot}/.asil/skills`, + repoRoot, + queuePath: `${repoRoot}/.asil/usage-data/queue.json`, skipCategories: [], + // Set true only when this process should push and open a pull request. + openPullRequests: false, codexConfig: { apiKey: 'OPENAI_API_KEY', model: 'gpt-4o' }, }, -}); + { + llm: createAnthropicCaller(process.env.ANTHROPIC_API_KEY!), + codex: createCodexCaller(process.env.OPENAI_API_KEY!), + git: createGitOps(repoRoot), + tracker: cost.tracker, + budgetManager: cost.budgetManager, + runner: createCommandRunner({ env: scrubbedEnv() }), + fileReader: createFileReader(), + fileFetcher: createFileFetcher(), + diff: createDiffApplier(), + }, +); ``` -`LLMCaller` is a tiny mock-friendly interface (`call(systemPrompt, userPrompt, model) → Promise<{content, inputTokens, outputTokens}>`). The shipped wirings cover Anthropic and OpenAI; swapping in a local model, a self-hosted relay, or a different provider is a one-file change. +`LLMCaller` is a tiny mock-friendly interface (`call(systemPrompt, userPrompt, model) → Promise<{content, inputTokens, outputTokens}>`). The shipped wirings cover Anthropic, OpenAI, and any OpenAI-compatible HTTP server. ## Key innovations diff --git a/examples/quickstart.md b/examples/quickstart.md index 4ec6552..393ab64 100644 --- a/examples/quickstart.md +++ b/examples/quickstart.md @@ -19,17 +19,13 @@ packages: - "asil/packages/*" # add this line ``` -`pnpm install` will link them in. Or, once published, install the library packages from npm: +`pnpm install` will link them in. The library packages are not on npm yet, so install them from this git repository rather than with `pnpm add`. -```bash -pnpm add asil-cost-controller asil-thought-multiplier asil-improvement-loop asil-analyzer -``` - -(`asil-runners` is the reference CLI/app, not a published library — vendor it or copy its wiring. The four libraries above are what you compose into your own runner.) +(`asil-runners` is the reference CLI, not a library — vendor it or copy its wiring. The four libraries above are what you compose into your own runner.) ## 2. Set up environment -Create `.env` in your repo root: +Create `.env` next to `pnpm-workspace.yaml` in the ASIL checkout. The CLI loads that file and no other: ``` ANTHROPIC_API_KEY=sk-ant-… @@ -39,16 +35,24 @@ REPO_ROOT=/absolute/path/to/your/repo ## 3. Drop in the runner skills -ASIL's thinkers expect Markdown skill files at `/.asil/skills/`. The simplest setup: +Thinkers and the executor load Markdown from `ASIL_SKILLS_PATH` (default `/.asil/skills`). Both of these layouts work, with the flat file tried first: + +```bash +# Flat file +.asil/skills/planning-and-task-breakdown.md + +# Directory layout used by many public skill libraries +.asil/skills/planning-and-task-breakdown/SKILL.md +``` + +Historical names such as `security-review` also resolve to `security-and-hardening`. A missing file falls back to the inline instructions. ```bash mkdir -p .asil/skills -# Copy or symlink any Markdown skills (e.g. from a public skill library) you want to use. -# A minimal install is fine — the thinkers fall back to inline defaults -# if a skill file is missing. +# Copy or symlink the skill files you want. A minimal install is fine. ``` -Override the location with `ASIL_SKILLS_PATH` if you want them somewhere else. +Override the location with `ASIL_SKILLS_PATH`. A relative value must stay inside the repository. An absolute value is used as given. ## 4. Run System B (Thought Multiplier) standalone @@ -66,19 +70,19 @@ You'll get a Markdown brief with merged recommendations, conflict resolutions, a pnpm --filter asil-runners auto grind --dry-run ``` -This scans, queues tasks, and reports — without touching git or making any LLM calls past the scan. Use it to verify the scanner picks up what you expect. +This scans and reports without executing tasks, calling a model, or changing git. The scanner does run the target repository's test, typecheck, and search commands, so it is not a no-op. Use it to verify the scanner picks up what you expect. ## 6. Run a real grind (small budget) ```bash -pnpm --filter asil-runners auto grind --max-tasks 3 --skip dependency-update,documentation +pnpm --filter asil-runners auto grind --max-tasks 3 --skip dependency-update,documentation --apply ``` The runner will: 1. Scan and queue tasks 2. Pause on any unresolved `// DOMAIN_QUESTION:` markers and offer triage -3. For each task: isolate, execute, typecheck, test, self-review × 3, adversarial gate, domain guard, open PR +3. For each task: isolate, execute, typecheck, test, self-review × 3, adversarial gate, domain guard, then commit locally. `--apply` also pushes and opens a pull request. 4. Print a summary with cost spent and PRs opened ## 7. Inspect the spend diff --git a/packages/asil-analyzer/package.json b/packages/asil-analyzer/package.json index 82f2f34..69676a3 100644 --- a/packages/asil-analyzer/package.json +++ b/packages/asil-analyzer/package.json @@ -9,7 +9,7 @@ "files": ["dist", "README.md"], "repository": { "type": "git", - "url": "git+https://github.com/telivity-otaip/asil.git", + "url": "git+https://github.com/TelivityAI/asil.git", "directory": "packages/asil-analyzer" }, "publishConfig": { "access": "public" }, diff --git a/packages/asil-cost-controller/README.md b/packages/asil-cost-controller/README.md index 2254886..3600fb8 100644 --- a/packages/asil-cost-controller/README.md +++ b/packages/asil-cost-controller/README.md @@ -1,5 +1,5 @@ # asil-cost-controller -Token-budget governor for autonomous LLM agents — per-task budgets, checkpoints, kill switch, daily caps. Part of [ASIL](https://github.com/telivity-otaip/asil). +Token-budget governor for autonomous LLM agents — per-task budgets, checkpoints, kill switch, daily caps. Part of [ASIL](https://github.com/TelivityAI/asil). -See the [ASIL README](https://github.com/telivity-otaip/asil#readme) for the full architecture. +See the [ASIL README](https://github.com/TelivityAI/asil#readme) for the full architecture. diff --git a/packages/asil-cost-controller/package.json b/packages/asil-cost-controller/package.json index 623f7bc..1a25dbf 100644 --- a/packages/asil-cost-controller/package.json +++ b/packages/asil-cost-controller/package.json @@ -9,7 +9,7 @@ "files": ["dist", "README.md"], "repository": { "type": "git", - "url": "git+https://github.com/telivity-otaip/asil.git", + "url": "git+https://github.com/TelivityAI/asil.git", "directory": "packages/asil-cost-controller" }, "publishConfig": { "access": "public" }, diff --git a/packages/asil-improvement-loop/README.md b/packages/asil-improvement-loop/README.md index d1d66b1..2c6ef11 100644 --- a/packages/asil-improvement-loop/README.md +++ b/packages/asil-improvement-loop/README.md @@ -1,5 +1,5 @@ # asil-improvement-loop -The autonomous code-improvement pipeline — scan, isolate, execute, 3-persona self-review, adversarial gate, domain guard, PR. Part of [ASIL](https://github.com/telivity-otaip/asil). +The autonomous code-improvement pipeline — scan, isolate, execute, 3-persona self-review, adversarial gate, domain guard, PR. Part of [ASIL](https://github.com/TelivityAI/asil). -See the [ASIL README](https://github.com/telivity-otaip/asil#readme) for the full architecture. +See the [ASIL README](https://github.com/TelivityAI/asil#readme) for the full architecture. diff --git a/packages/asil-improvement-loop/package.json b/packages/asil-improvement-loop/package.json index 0c9b330..367f261 100644 --- a/packages/asil-improvement-loop/package.json +++ b/packages/asil-improvement-loop/package.json @@ -9,7 +9,7 @@ "files": ["dist", "README.md"], "repository": { "type": "git", - "url": "git+https://github.com/telivity-otaip/asil.git", + "url": "git+https://github.com/TelivityAI/asil.git", "directory": "packages/asil-improvement-loop" }, "publishConfig": { "access": "public" }, diff --git a/packages/asil-improvement-loop/src/__tests__/scanner.test.ts b/packages/asil-improvement-loop/src/__tests__/scanner.test.ts index e4721d0..93a2d39 100644 --- a/packages/asil-improvement-loop/src/__tests__/scanner.test.ts +++ b/packages/asil-improvement-loop/src/__tests__/scanner.test.ts @@ -131,7 +131,7 @@ describe('scanner', () => { // pnpm-recursive output prefixes every line with `