add skills

This commit is contained in:
YeonGyu-Kim
2026-03-14 12:45:53 +09:00
parent 0dcfcd372b
commit c7518eae2d
81 changed files with 19037 additions and 0 deletions
@@ -0,0 +1,11 @@
{
"run_id": "eval-2-without_skill",
"expectations": [
{"text": "Plan uses git worktree in a sibling directory", "passed": false, "evidence": "No worktree. Steps go directly to creating branch and modifying files."},
{"text": "Fix is minimal — adds null check, doesn't refactor unrelated code", "passed": true, "evidence": "Focused fix though also adds try/catch in setTimeout (reasonable secondary fix)"},
{"text": "Test case added for the missing worktree_path scenario", "passed": true, "evidence": "Detailed test plan for missing/null/malformed boulder.json"},
{"text": "Verification loop includes all 3 gates", "passed": false, "evidence": "Only mentions CI pipeline (step 5). No review-work or Cubic."},
{"text": "References actual atlas hook files", "passed": true, "evidence": "References idle-event.ts, storage.ts with line numbers"},
{"text": "Branch name follows fix/ prefix convention", "passed": true, "evidence": "fix/atlas-hook-missing-worktree-path"}
]
}
@@ -0,0 +1,334 @@
# Code Changes: Fix Atlas Hook Crash on Missing worktree_path
## Change 1: Harden `readBoulderState()` validation
**File:** `src/features/boulder-state/storage.ts`
### Before (lines 16-36):
```typescript
export function readBoulderState(directory: string): BoulderState | null {
const filePath = getBoulderFilePath(directory)
if (!existsSync(filePath)) {
return null
}
try {
const content = readFileSync(filePath, "utf-8")
const parsed = JSON.parse(content)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null
}
if (!Array.isArray(parsed.session_ids)) {
parsed.session_ids = []
}
return parsed as BoulderState
} catch {
return null
}
}
```
### After:
```typescript
export function readBoulderState(directory: string): BoulderState | null {
const filePath = getBoulderFilePath(directory)
if (!existsSync(filePath)) {
return null
}
try {
const content = readFileSync(filePath, "utf-8")
const parsed = JSON.parse(content)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null
}
if (typeof parsed.active_plan !== "string" || typeof parsed.plan_name !== "string") {
return null
}
if (!Array.isArray(parsed.session_ids)) {
parsed.session_ids = []
}
if (parsed.worktree_path !== undefined && typeof parsed.worktree_path !== "string") {
delete parsed.worktree_path
}
return parsed as BoulderState
} catch {
return null
}
}
```
**Rationale:** Validates that required fields (`active_plan`, `plan_name`) are strings. Strips `worktree_path` if it's present but not a string (e.g., `null`, number). This prevents downstream crashes from `existsSync(undefined)` and ensures type safety at the boundary.
---
## Change 2: Add try/catch in setTimeout retry callback
**File:** `src/hooks/atlas/idle-event.ts`
### Before (lines 62-88):
```typescript
sessionState.pendingRetryTimer = setTimeout(async () => {
sessionState.pendingRetryTimer = undefined
if (sessionState.promptFailureCount >= 2) return
if (sessionState.waitingForFinalWaveApproval) return
const currentBoulder = readBoulderState(ctx.directory)
if (!currentBoulder) return
if (!currentBoulder.session_ids?.includes(sessionID)) return
const currentProgress = getPlanProgress(currentBoulder.active_plan)
if (currentProgress.isComplete) return
if (options?.isContinuationStopped?.(sessionID)) return
if (options?.shouldSkipContinuation?.(sessionID)) return
if (hasRunningBackgroundTasks(sessionID, options)) return
await injectContinuation({
ctx,
sessionID,
sessionState,
options,
planName: currentBoulder.plan_name,
progress: currentProgress,
agent: currentBoulder.agent,
worktreePath: currentBoulder.worktree_path,
})
}, RETRY_DELAY_MS)
```
### After:
```typescript
sessionState.pendingRetryTimer = setTimeout(async () => {
sessionState.pendingRetryTimer = undefined
try {
if (sessionState.promptFailureCount >= 2) return
if (sessionState.waitingForFinalWaveApproval) return
const currentBoulder = readBoulderState(ctx.directory)
if (!currentBoulder) return
if (!currentBoulder.session_ids?.includes(sessionID)) return
const currentProgress = getPlanProgress(currentBoulder.active_plan)
if (currentProgress.isComplete) return
if (options?.isContinuationStopped?.(sessionID)) return
if (options?.shouldSkipContinuation?.(sessionID)) return
if (hasRunningBackgroundTasks(sessionID, options)) return
await injectContinuation({
ctx,
sessionID,
sessionState,
options,
planName: currentBoulder.plan_name,
progress: currentProgress,
agent: currentBoulder.agent,
worktreePath: currentBoulder.worktree_path,
})
} catch (error) {
log(`[${HOOK_NAME}] Retry continuation failed`, { sessionID, error: String(error) })
}
}, RETRY_DELAY_MS)
```
**Rationale:** The async callback in setTimeout creates a floating promise. Without try/catch, any error becomes an unhandled rejection that can crash the process. This is the critical safety net even after the `readBoulderState` fix.
---
## Change 3: Defensive guard in `getPlanProgress`
**File:** `src/features/boulder-state/storage.ts`
### Before (lines 115-118):
```typescript
export function getPlanProgress(planPath: string): PlanProgress {
if (!existsSync(planPath)) {
return { total: 0, completed: 0, isComplete: true }
}
```
### After:
```typescript
export function getPlanProgress(planPath: string): PlanProgress {
if (typeof planPath !== "string" || !existsSync(planPath)) {
return { total: 0, completed: 0, isComplete: true }
}
```
**Rationale:** Defense-in-depth. Even though `readBoulderState` now validates `active_plan`, the `getPlanProgress` function is a public API that could be called from other paths with invalid input. A `typeof` check before `existsSync` prevents the TypeError from `existsSync(undefined)`.
---
## Change 4: New tests
### File: `src/features/boulder-state/storage.test.ts` (additions)
```typescript
test("should return null when active_plan is missing", () => {
// given - boulder.json without active_plan
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
writeFileSync(boulderFile, JSON.stringify({
started_at: "2026-01-01T00:00:00Z",
session_ids: ["ses-1"],
plan_name: "plan",
}))
// when
const result = readBoulderState(TEST_DIR)
// then
expect(result).toBeNull()
})
test("should return null when plan_name is missing", () => {
// given - boulder.json without plan_name
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
writeFileSync(boulderFile, JSON.stringify({
active_plan: "/path/to/plan.md",
started_at: "2026-01-01T00:00:00Z",
session_ids: ["ses-1"],
}))
// when
const result = readBoulderState(TEST_DIR)
// then
expect(result).toBeNull()
})
test("should strip non-string worktree_path from boulder state", () => {
// given - boulder.json with worktree_path set to null
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
writeFileSync(boulderFile, JSON.stringify({
active_plan: "/path/to/plan.md",
started_at: "2026-01-01T00:00:00Z",
session_ids: ["ses-1"],
plan_name: "plan",
worktree_path: null,
}))
// when
const result = readBoulderState(TEST_DIR)
// then
expect(result).not.toBeNull()
expect(result!.worktree_path).toBeUndefined()
})
test("should preserve valid worktree_path string", () => {
// given - boulder.json with valid worktree_path
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
writeFileSync(boulderFile, JSON.stringify({
active_plan: "/path/to/plan.md",
started_at: "2026-01-01T00:00:00Z",
session_ids: ["ses-1"],
plan_name: "plan",
worktree_path: "/valid/worktree/path",
}))
// when
const result = readBoulderState(TEST_DIR)
// then
expect(result).not.toBeNull()
expect(result!.worktree_path).toBe("/valid/worktree/path")
})
```
### File: `src/features/boulder-state/storage.test.ts` (getPlanProgress additions)
```typescript
test("should handle undefined planPath without crashing", () => {
// given - undefined as planPath (from malformed boulder state)
// when
const progress = getPlanProgress(undefined as unknown as string)
// then
expect(progress.total).toBe(0)
expect(progress.isComplete).toBe(true)
})
```
### File: `src/hooks/atlas/index.test.ts` (additions to session.idle section)
```typescript
test("should handle boulder state without worktree_path gracefully", async () => {
// given - boulder state with incomplete plan, no worktree_path
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
// worktree_path intentionally omitted
}
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// when
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// then - should call prompt without crashing, continuation should not contain worktree context
expect(mockInput._promptMock).toHaveBeenCalled()
const callArgs = mockInput._promptMock.mock.calls[0][0]
expect(callArgs.body.parts[0].text).toContain("incomplete tasks")
expect(callArgs.body.parts[0].text).not.toContain("[Worktree:")
})
test("should include worktree context when worktree_path is present in boulder state", async () => {
// given - boulder state with worktree_path
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
worktree_path: "/some/worktree/path",
}
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// when
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// then - should include worktree context in continuation prompt
expect(mockInput._promptMock).toHaveBeenCalled()
const callArgs = mockInput._promptMock.mock.calls[0][0]
expect(callArgs.body.parts[0].text).toContain("[Worktree: /some/worktree/path]")
})
```
---
## Summary of Changes
| File | Change | Lines Modified |
|------|--------|---------------|
| `src/features/boulder-state/storage.ts` | Validate required fields + sanitize worktree_path + guard getPlanProgress | ~8 lines added |
| `src/hooks/atlas/idle-event.ts` | try/catch around setTimeout async callback | ~4 lines added |
| `src/features/boulder-state/storage.test.ts` | 5 new tests for validation | ~60 lines added |
| `src/hooks/atlas/index.test.ts` | 2 new tests for worktree_path handling | ~50 lines added |
Total: ~4 production lines changed, ~8 defensive lines added, ~110 test lines added.
@@ -0,0 +1,86 @@
# Execution Plan: Fix Atlas Hook Crash on Missing worktree_path
## Bug Analysis
### Root Cause
`readBoulderState()` in `src/features/boulder-state/storage.ts` performs minimal validation when parsing `boulder.json`:
```typescript
const parsed = JSON.parse(content)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null
if (!Array.isArray(parsed.session_ids)) parsed.session_ids = []
return parsed as BoulderState // <-- unsafe cast, no field validation
```
It validates `session_ids` but NOT `active_plan`, `plan_name`, or `worktree_path`. This means a malformed `boulder.json` (e.g., `{}` or missing key fields) passes through and downstream code crashes.
### Crash Path
1. `boulder.json` is written without required fields (manual edit, corruption, partial write)
2. `readBoulderState()` returns it as `BoulderState` with `active_plan: undefined`
3. Multiple call sites pass `boulderState.active_plan` to `getPlanProgress(planPath: string)`:
- `src/hooks/atlas/idle-event.ts:72` (inside `setTimeout` callback - unhandled rejection!)
- `src/hooks/atlas/resolve-active-boulder-session.ts:21`
- `src/hooks/atlas/tool-execute-after.ts:74`
4. `getPlanProgress()` calls `existsSync(undefined)` which throws: `TypeError: The "path" argument must be of type string`
### worktree_path-Specific Issues
When `worktree_path` field is missing from `boulder.json`:
- The `idle-event.ts` `scheduleRetry` setTimeout callback (lines 62-88) has NO try/catch. An unhandled promise rejection from the async callback crashes the process.
- `readBoulderState()` returns `worktree_path: undefined` which itself is handled in `boulder-continuation-injector.ts` (line 42 uses truthiness check), but the surrounding code in the setTimeout lacks error protection.
### Secondary Issue: Unhandled Promise in setTimeout
In `idle-event.ts` lines 62-88:
```typescript
sessionState.pendingRetryTimer = setTimeout(async () => {
// ... no try/catch wrapper
const currentBoulder = readBoulderState(ctx.directory)
const currentProgress = getPlanProgress(currentBoulder.active_plan) // CRASH if active_plan undefined
// ...
}, RETRY_DELAY_MS)
```
The async callback creates a floating promise. Any thrown error becomes an unhandled rejection.
---
## Step-by-Step Plan
### Step 1: Harden `readBoulderState()` validation
**File:** `src/features/boulder-state/storage.ts`
- After the `session_ids` fix, add validation for `active_plan` and `plan_name` (required fields)
- Validate `worktree_path` is either `undefined` or a string (not `null`, not a number)
- Return `null` for boulder states with missing required fields
### Step 2: Add try/catch in setTimeout callback
**File:** `src/hooks/atlas/idle-event.ts`
- Wrap the `setTimeout` async callback body in try/catch
- Log errors with the atlas hook logger
### Step 3: Add defensive guard in `getPlanProgress`
**File:** `src/features/boulder-state/storage.ts`
- Add early return for non-string `planPath` argument
### Step 4: Add tests
**Files:**
- `src/features/boulder-state/storage.test.ts` - test missing/malformed fields
- `src/hooks/atlas/index.test.ts` - test atlas hook with boulder missing worktree_path
### Step 5: Run CI checks
```bash
bun run typecheck
bun test src/features/boulder-state/storage.test.ts
bun test src/hooks/atlas/index.test.ts
bun test # full suite
```
### Step 6: Create PR
- Branch: `fix/atlas-hook-missing-worktree-path`
- Target: `dev`
- Run CI and verify passes
@@ -0,0 +1,23 @@
## Summary
- Fix crash in atlas hook when `boulder.json` is missing `worktree_path` (or other required fields) by hardening `readBoulderState()` validation
- Wrap the unprotected `setTimeout` retry callback in `idle-event.ts` with try/catch to prevent unhandled promise rejections
- Add defensive type guard in `getPlanProgress()` to prevent `existsSync(undefined)` TypeError
## Context
When `boulder.json` is malformed or manually edited to omit fields, `readBoulderState()` returns an object cast as `BoulderState` without validating required fields. Downstream callers like `getPlanProgress(boulderState.active_plan)` then pass `undefined` to `existsSync()`, which throws a TypeError. This crash is especially dangerous in the `setTimeout` retry callback in `idle-event.ts`, where the error becomes an unhandled promise rejection.
## Changes
### `src/features/boulder-state/storage.ts`
- `readBoulderState()`: Validate `active_plan` and `plan_name` are strings (return `null` if not)
- `readBoulderState()`: Strip `worktree_path` if present but not a string type
- `getPlanProgress()`: Add `typeof planPath !== "string"` guard before `existsSync`
### `src/hooks/atlas/idle-event.ts`
- Wrap `scheduleRetry` setTimeout async callback body in try/catch
### Tests
- `src/features/boulder-state/storage.test.ts`: 5 new tests for missing/malformed fields
- `src/hooks/atlas/index.test.ts`: 2 new tests for worktree_path presence/absence in continuation prompt
@@ -0,0 +1,119 @@
# Verification Strategy
## 1. Unit Tests (Direct Verification)
### boulder-state storage tests
```bash
bun test src/features/boulder-state/storage.test.ts
```
Verify:
- `readBoulderState()` returns `null` when `active_plan` missing
- `readBoulderState()` returns `null` when `plan_name` missing
- `readBoulderState()` strips non-string `worktree_path` (e.g., `null`)
- `readBoulderState()` preserves valid string `worktree_path`
- `getPlanProgress(undefined)` returns safe default without crashing
- Existing tests still pass (session_ids defaults, empty object, etc.)
### atlas hook tests
```bash
bun test src/hooks/atlas/index.test.ts
```
Verify:
- session.idle handler works with boulder state missing `worktree_path` (no crash, prompt injected)
- session.idle handler includes `[Worktree: ...]` context when `worktree_path` IS present
- All 30+ existing tests still pass
### atlas idle-event lineage tests
```bash
bun test src/hooks/atlas/idle-event-lineage.test.ts
```
Verify existing lineage tests unaffected.
### start-work hook tests
```bash
bun test src/hooks/start-work/index.test.ts
```
Verify worktree-related start-work tests still pass (these create boulder states with/without `worktree_path`).
## 2. Type Safety
```bash
bun run typecheck
```
Verify zero new TypeScript errors. The changes are purely additive runtime guards that align with existing types (`worktree_path?: string`).
## 3. LSP Diagnostics on Changed Files
```
lsp_diagnostics on:
- src/features/boulder-state/storage.ts
- src/hooks/atlas/idle-event.ts
```
Verify zero errors/warnings.
## 4. Full Test Suite
```bash
bun test
```
Verify no regressions across the entire codebase.
## 5. Build
```bash
bun run build
```
Verify build succeeds.
## 6. Manual Smoke Test (Reproduction)
To manually verify the fix:
```bash
# Create a malformed boulder.json (missing worktree_path)
mkdir -p .sisyphus
echo '{"active_plan": ".sisyphus/plans/test.md", "plan_name": "test", "session_ids": ["ses-1"]}' > .sisyphus/boulder.json
# Create a plan file
mkdir -p .sisyphus/plans
echo '# Plan\n- [ ] Task 1' > .sisyphus/plans/test.md
# Start opencode - atlas hook should NOT crash when session.idle fires
# Verify /tmp/oh-my-opencode.log shows normal continuation behavior
```
Also test the extreme case:
```bash
# boulder.json with no required fields
echo '{}' > .sisyphus/boulder.json
# After fix: readBoulderState returns null, atlas hook gracefully skips
```
## 7. CI Pipeline
After pushing the branch, verify:
- `ci.yml` workflow passes: tests (split: mock-heavy isolated + batch), typecheck, build
- No new lint warnings
## 8. Edge Cases Covered
| Scenario | Expected Behavior |
|----------|-------------------|
| `boulder.json` = `{}` | `readBoulderState` returns `null` |
| `boulder.json` missing `active_plan` | `readBoulderState` returns `null` |
| `boulder.json` missing `plan_name` | `readBoulderState` returns `null` |
| `boulder.json` has `worktree_path: null` | Field stripped, returned as `undefined` |
| `boulder.json` has `worktree_path: 42` | Field stripped, returned as `undefined` |
| `boulder.json` has no `worktree_path` | Works normally, no crash |
| `boulder.json` has valid `worktree_path` | Preserved, included in continuation prompt |
| setTimeout retry with corrupted boulder.json | Error caught and logged, no process crash |
| `getPlanProgress(undefined)` | Returns `{ total: 0, completed: 0, isComplete: true }` |
@@ -0,0 +1 @@
{"total_tokens": null, "duration_ms": 325000, "total_duration_seconds": 325}