Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
All notable changes are documented here. AI Optimizer follows semantic
versioning.

## 0.1.7 - 2026-08-23

- Warn (and therefore fail `--strict`) when a linked skill directory cannot be
used, while keeping its path out of both human and JSON reports.
- Document Homebrew's native, opt-in 19:30 service as the recommended scheduled
maintenance path for Homebrew installations.

## 0.1.6 - 2026-08-22

- Keep the configured AI Optimizer executable out of the launchd plist
Expand Down
28 changes: 24 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ skills, repositories, or launchd unless you explicitly add `--schedule`.
Example:

```text
AI Optimizer 0.1.6
AI Optimizer 0.1.7

[PASS] system.macos - macOS is supported
[PASS] tools.claude.present - Claude Code is available
Expand Down Expand Up @@ -67,6 +67,23 @@ directory.

Scheduling is opt-in:

```sh
brew services start nyldn/tap/ai-optimizer
```

For Homebrew installs, this is the recommended path. Homebrew creates a
user-level launchd job that runs at 19:30 local time, does not run when first
loaded, and resolves the stable `opt` path across package upgrades. Check or
remove it with:

```sh
brew services info nyldn/tap/ai-optimizer
brew services stop nyldn/tap/ai-optimizer
```

Do not enable both schedulers. For a checksum-verified direct install, or when
you need a custom time, use AI Optimizer's own scheduler:

```sh
ai-optimizer schedule
```
Expand All @@ -76,7 +93,7 @@ checks the time again when launchd actually starts the process. A Mac waking
later in the morning records `skipped_outside_window` and performs no scan.
Configuration, receipts, and scheduler logs are stored with owner-only
permissions.
The launch agent uses an owner-only, product-owned maintenance launcher under
The direct-install launch agent uses an owner-only, product-owned maintenance launcher under
Application Support. Its versioned filename is stable for the configured
executable path, and the Homebrew or direct-install path does not appear in the
launchd plist. Package upgrades therefore leave an already opted-in schedule
Expand Down Expand Up @@ -121,7 +138,7 @@ The direct path verifies the installer before it runs, then the installer
verifies the release archive before changing live paths:

```sh
VERSION=0.1.6
VERSION=0.1.7
curl -fLO "https://github.com/nyldn/ai-optimizer/releases/download/v$VERSION/install.sh"
curl -fLO "https://github.com/nyldn/ai-optimizer/releases/download/v$VERSION/install.sh.sha256"
shasum -a 256 -c install.sh.sha256
Expand All @@ -138,10 +155,13 @@ Homebrew:

```sh
brew upgrade ai-optimizer
ai-optimizer unschedule
brew services stop nyldn/tap/ai-optimizer
brew uninstall ai-optimizer
```

If you used `ai-optimizer schedule` instead, run `ai-optimizer unschedule`
before uninstalling.

Direct install:

```sh
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.6
0.1.7
19 changes: 12 additions & 7 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ flowchart LR
D --> E[Finding objects]
E --> F[Human report]
E --> G[JSON report]
H[Explicit setup or schedule] --> I[Owned config and launchd state]
H[Explicit setup or schedule] --> I[Owned config and direct-install launchd state]
J[Explicit brew services start] --> K[Homebrew-managed launchd state]
```

## Runtime
Expand All @@ -33,9 +34,13 @@ exits 3.
## Ownership

Configuration and reports live under
`~/Library/Application Support/io.github.nyldn.ai-optimizer`. User automation is the exact
label `io.github.nyldn.ai-optimizer.daily`. Writes reject symlink targets,
stage and validate before rename, and record product provenance.

The launch agent uses `launchctl bootstrap`, `bootout`, and `print` in the
current user's GUI domain. It never uses cron.
`~/Library/Application Support/io.github.nyldn.ai-optimizer`. The direct-install
scheduler uses the exact label `io.github.nyldn.ai-optimizer.daily`. Writes
reject symlink targets, stage and validate before rename, and record product
provenance.

Homebrew installations can instead opt into the formula service, whose exact
label is `homebrew.mxcl.ai-optimizer`. Its command resolves through Homebrew's
stable `opt` path and its 19:30 schedule is generated by Homebrew's public
service DSL. The direct scheduler uses `launchctl bootstrap`, `bootout`, and
`print` in the current user's GUI domain. Neither path writes a user crontab.
2 changes: 1 addition & 1 deletion docs/privacy.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Privacy

AI Optimizer 0.1.6 has no telemetry and sends no diagnostic data anywhere.
AI Optimizer 0.1.7 has no telemetry and sends no diagnostic data anywhere.

## Read

Expand Down
2 changes: 2 additions & 0 deletions docs/research-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ project code was copied.
| Visible background failure | [gstack crash sentinel, 2026-08-15](https://github.com/garrytan/gstack/commit/008dd65b1fc3df8af618408f5aea37a24dcea411) | Silence cannot mean success; every scheduled run has a receipt. |
| Validate before replace | [cc-switch backup source, current head 2026-08-22](https://github.com/farion1231/cc-switch/blob/5ca9459d50ea4beea6a81bbc509de6ec5b6b09ca/src-tauri/src/database/backup.rs) | Stage and validate before changing last known-good state. |
| One recommended install path | [Matt Pocock skills, current head 2026-08-22](https://github.com/mattpocock/skills/blob/5b15a47f2d7150f545fbcacbfe381787fc0230dc/README.md) | Homebrew is primary; direct install is a clearly labeled fallback. |
| Native service lifecycle | [Homebrew service DSL public API, 2026-07-26](https://github.com/Homebrew/brew/commit/29cb5e338a49290d813aa063312b7b79c5d10732) and [Formula Cookbook](https://docs.brew.sh/Formula-Cookbook#service-files) | Homebrew installs use `brew services` with a stable `opt` command and an explicit 19:30 cron expression; no user crontab is created. |
| Precise linked-skill failures | [fx linked-skill diagnostic, 2026-08-22](https://github.com/vercel-labs/fx/commit/c6d210b1fe47808e1a3553b17a022bfb82831a89) | Broken linked skill directories warn separately from invalid frontmatter, without leaking paths. |

The resulting product boundary is intentional: AI Optimizer is a macOS
observer and owner of its own maintenance state. It is not another package
Expand Down
12 changes: 12 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ fail CI.

## The launch agent is not loaded

For a Homebrew install:

```sh
brew services info nyldn/tap/ai-optimizer
brew services restart nyldn/tap/ai-optimizer
```

For a direct install or custom schedule:

```sh
ai-optimizer schedule status
ai-optimizer unschedule
Expand All @@ -32,6 +41,9 @@ ai-optimizer schedule
The scheduler is idempotent and reconciles file and launchd state. It owns only
`io.github.nyldn.ai-optimizer.daily`.

Do not run both scheduling modes. Stop the Homebrew service before enabling a
custom AI Optimizer schedule.

## A morning run says skipped

launchd coalesces calendar events missed while a Mac sleeps and may start the
Expand Down
32 changes: 29 additions & 3 deletions lib/ai_optimizer/checks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -268,11 +268,18 @@ def call
]
names = []
invalid = 0
unavailable_links = 0
roots.each do |root|
next unless Dir.exist?(root)

Dir.children(root).sort.each do |name|
skill_file = File.join(root, name, "SKILL.md")
candidate = File.join(root, name)
if unavailable_link?(candidate)
unavailable_links += 1
next
end

skill_file = File.join(candidate, "SKILL.md")
next unless File.file?(skill_file)

names << name
Expand All @@ -287,14 +294,25 @@ def call
else
"pass"
end
Finding.new(
findings = [Finding.new(
id: "skills.inventory", category: "skills", status: status,
message: "Skill inventory inspected",
detail: "#{names.length} skills, #{duplicates} names shared across tool roots, " \
"#{invalid} invalid frontmatter files",
remediation: invalid.positive? ? "Fix invalid SKILL.md frontmatter before using those skills." : nil,
required: false, affects: ["claude", "codex"]
).yield_self { |finding| [finding] }
)]
if unavailable_links.positive?
noun = unavailable_links == 1 ? "directory" : "directories"
findings << Finding.new(
id: "skills.linked_candidates", category: "skills", status: "warn",
message: "Some linked skill directories are unavailable",
detail: "#{unavailable_links} linked skill #{noun} could not be used; paths were omitted.",
remediation: "Repair or remove broken skill links, then rerun ai-optimizer doctor.",
required: false, affects: ["claude", "codex"]
)
end
findings
end

def required?
Expand All @@ -303,6 +321,14 @@ def required?

private

def unavailable_link?(path)
return false unless File.symlink?(path)

!File.directory?(File.realpath(path))
rescue SystemCallError
true
end

def valid_frontmatter?(path)
content = File.binread(path, 16_384)
return false unless content.start_with?("---\n")
Expand Down
89 changes: 89 additions & 0 deletions test/checks_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,95 @@ def test_mcp_details_never_expose_endpoints_or_tokens
end
end

def test_broken_linked_skill_directory_is_reported_without_its_path
in_tmpdir do |dir|
skills_root = File.join(dir, ".codex", "skills")
FileUtils.mkdir_p(skills_root)
private_name = "confidential-missing-skill"
File.symlink(File.join(dir, private_name), File.join(skills_root, private_name))
context = AIOptimizer::CheckContext.new(
home: dir, data_dir: File.join(dir, "data"), workspace_root: dir,
path: "", runner: TestSupport::FakeCommandRunner.new,
platform: "darwin", architecture: "arm64", macos_version: "15.6"
)

report = AIOptimizer::Doctor.new(context).run
finding = report.findings.find { |item| item.id == "skills.linked_candidates" }

assert_equal "warn", finding.status
assert_includes finding.detail, "1 linked skill directory"
refute_includes report.to_json, private_name
end
end

def test_linked_skill_with_non_directory_path_component_does_not_hide_inventory
in_tmpdir do |dir|
skills_root = File.join(dir, ".codex", "skills")
FileUtils.mkdir_p(File.join(skills_root, "valid"))
File.write(File.join(skills_root, "valid", "SKILL.md"), "---\nname: valid\ndescription: Valid fixture\n---\n")
plain_file = File.join(dir, "plain-file")
File.write(plain_file, "not a directory")
File.symlink(File.join(plain_file, "child"), File.join(skills_root, "unavailable"))
context = AIOptimizer::CheckContext.new(
home: dir, data_dir: File.join(dir, "data"), workspace_root: dir,
path: "", runner: TestSupport::FakeCommandRunner.new,
platform: "darwin", architecture: "arm64", macos_version: "15.6"
)

report = AIOptimizer::Doctor.new(context).run
inventory = report.findings.find { |item| item.id == "skills.inventory" }
linked = report.findings.find { |item| item.id == "skills.linked_candidates" }

assert_includes inventory.detail, "1 skills"
assert_equal "warn", linked.status
refute report.findings.any? { |item| item.id == "checks.skills_check.unknown" }
end
end

def test_linked_regular_file_is_not_counted_as_a_skill_directory
in_tmpdir do |dir|
skills_root = File.join(dir, ".codex", "skills")
FileUtils.mkdir_p(skills_root)
regular_file = File.join(dir, "not-a-skill-directory")
File.write(regular_file, "fixture")
File.symlink(regular_file, File.join(skills_root, "linked-file"))
context = AIOptimizer::CheckContext.new(
home: dir, data_dir: File.join(dir, "data"), workspace_root: dir,
path: "", runner: TestSupport::FakeCommandRunner.new,
platform: "darwin", architecture: "arm64", macos_version: "15.6"
)

finding = AIOptimizer::Doctor.new(context).run.findings.find do |item|
item.id == "skills.linked_candidates"
end

assert_equal "warn", finding.status
assert_includes finding.detail, "could not be used"
end
end

def test_available_linked_skill_directory_remains_a_valid_skill
in_tmpdir do |dir|
source = File.join(dir, "shared", "valid-skill")
skills_root = File.join(dir, ".codex", "skills")
FileUtils.mkdir_p(source)
FileUtils.mkdir_p(skills_root)
File.write(File.join(source, "SKILL.md"), "---\nname: valid-skill\ndescription: Valid fixture\n---\n")
File.symlink(source, File.join(skills_root, "valid-skill"))
context = AIOptimizer::CheckContext.new(
home: dir, data_dir: File.join(dir, "data"), workspace_root: dir,
path: "", runner: TestSupport::FakeCommandRunner.new,
platform: "darwin", architecture: "arm64", macos_version: "15.6"
)

report = AIOptimizer::Doctor.new(context).run
inventory = report.findings.find { |item| item.id == "skills.inventory" }

assert_includes inventory.detail, "1 skills"
refute report.findings.any? { |item| item.id == "skills.linked_candidates" }
end
end

def test_workspace_scan_reports_counts_not_names
in_tmpdir do |dir|
root = File.join(dir, "workspaces")
Expand Down
Loading