Merge pull request #3943 from code-yeongyu/feature/boulder-evolution-and-discipline-agents
feat: boulder evolution + discipline agents (multi-work, timings, CLI, hooks, Oracle phase gates, no-excuses retry)
This commit is contained in:
@@ -127,3 +127,68 @@ describe("Atlas prompts use task_id (not session_id) for retries", () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Atlas prompts no-excuses retry policy", () => {
|
||||
test("no variant contains a numeric retry cap", () => {
|
||||
for (const [name, prompt] of ALL_VARIANTS) {
|
||||
expect(prompt, `${name}: must not impose Maximum N retries`).not.toMatch(/maximum\s+\d+\s+retr/i)
|
||||
expect(prompt, `${name}: must not impose N retries per task`).not.toMatch(/\d+\s+retries\s+per\s+task/i)
|
||||
expect(prompt, `${name}: must not impose N retry attempts`).not.toMatch(/\d+\s+retry\s+attempts/i)
|
||||
}
|
||||
})
|
||||
|
||||
test("no variant tells Atlas to move on after failure", () => {
|
||||
for (const [name, prompt] of ALL_VARIANTS) {
|
||||
const lower = prompt.toLowerCase()
|
||||
expect(lower, `${name}: must not tell Atlas to skip failed tasks`).not.toContain("document and continue to independent tasks")
|
||||
expect(lower, `${name}: must not tell Atlas to move to next independent task`).not.toContain("document and move to next independent task")
|
||||
expect(lower, `${name}: must not tell Atlas to move on`).not.toContain("then document and move on")
|
||||
}
|
||||
})
|
||||
|
||||
test("all variants forbid the false-positive excuse explicitly", () => {
|
||||
for (const [name, prompt] of ALL_VARIANTS) {
|
||||
const lower = prompt.toLowerCase()
|
||||
expect(lower, `${name}: missing false positive prohibition`).toContain("false positive")
|
||||
expect(lower, `${name}: missing no-retry-cap statement`).toContain("no retry cap")
|
||||
}
|
||||
})
|
||||
|
||||
test("all variants instruct subagent re-call with different angle when looping", () => {
|
||||
for (const [name, prompt] of ALL_VARIANTS) {
|
||||
const lower = prompt.toLowerCase()
|
||||
expect(lower, `${name}: missing different-angle subagent instruction`).toMatch(/different angle|new subagent/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Atlas prompts boulder-completion response", () => {
|
||||
test("all variants document the boulder-complete nudge response", () => {
|
||||
for (const [name, prompt] of ALL_VARIANTS) {
|
||||
expect(prompt, `${name}: missing boulder_completion_response section`).toContain("<boulder_completion_response>")
|
||||
expect(prompt, `${name}: missing BOULDER COMPLETE recognition phrase`).toContain("BOULDER COMPLETE")
|
||||
expect(prompt, `${name}: missing TOTAL ELAPSED summary field`).toContain("TOTAL ELAPSED")
|
||||
expect(prompt, `${name}: missing PER-TASK ELAPSED summary field`).toContain("PER-TASK ELAPSED")
|
||||
}
|
||||
})
|
||||
|
||||
test("all variants explain the one-shot nudge guarantee", () => {
|
||||
for (const [name, prompt] of ALL_VARIANTS) {
|
||||
const lower = prompt.toLowerCase()
|
||||
expect(lower, `${name}: missing one-shot nudge guarantee`).toMatch(/at most once|fires.*once/)
|
||||
}
|
||||
})
|
||||
|
||||
test("boulder completion section appears after the workflow", () => {
|
||||
for (const [name, prompt] of ALL_VARIANTS) {
|
||||
const workflowIdx = prompt.indexOf("<workflow>")
|
||||
const completionIdx = prompt.indexOf("<boulder_completion_response>")
|
||||
expect(workflowIdx, `${name}: missing workflow section`).toBeGreaterThan(-1)
|
||||
expect(completionIdx, `${name}: missing boulder completion section`).toBeGreaterThan(-1)
|
||||
expect(
|
||||
completionIdx,
|
||||
`${name}: boulder completion must come AFTER the workflow so the agent reads the failure rules first`,
|
||||
).toBeGreaterThan(workflowIdx)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -142,24 +142,28 @@ task(
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
### 3.5 Handle Failures (USE task_id)
|
||||
### 3.5 Handle Failures (USE task_id, NEVER GIVE UP)
|
||||
|
||||
Every \`task()\` output includes a task_id. STORE IT.
|
||||
|
||||
If task fails:
|
||||
1. Identify what went wrong
|
||||
2. **Resume the SAME session** - subagent has full context already:
|
||||
**Failure is never an excuse to stop or skip.** A subagent that reports success when verification fails is wrong, not "experiencing a false positive". "False positive" is not a valid reason in this codebase. If verification fails, the work is unfinished. There is no retry cap.
|
||||
|
||||
When a task fails:
|
||||
1. Diagnose what actually broke. Read the error, read the file, do not guess.
|
||||
2. **Resume the SAME session** so the subagent keeps its full context:
|
||||
\`\`\`typescript
|
||||
task(
|
||||
task_id="ses_xyz789",
|
||||
load_skills=[...],
|
||||
prompt="FAILED: {error}. Fix by: {specific instruction}"
|
||||
prompt="FAILED: {actual error output}. Diagnosis: {what you observed}. Fix by: {specific instruction}"
|
||||
)
|
||||
\`\`\`
|
||||
3. Maximum 3 retry attempts with the SAME session
|
||||
4. If blocked after 3 attempts: Document and continue to independent tasks
|
||||
3. If a single retry on the same session does not fix it, **plan the diagnosis explicitly**. Write down what the subagent attempted, what it observed, what hypothesis you have. Then resume the same session with that plan attached. Iterate until verification passes.
|
||||
4. If the subagent itself is the bottleneck (looping on the same broken approach), spawn a NEW subagent with a different angle. Pass the failed attempts as context so it does not repeat them. Stay on the same plan task; never move on with that task unverified.
|
||||
|
||||
**Why task_id is MANDATORY for failures:** subagent already read all files, knows what was tried, what failed. Starting fresh wipes that. 70%+ token savings on retries.
|
||||
**Why task_id is MANDATORY:** the subagent already read every relevant file, knows what was tried, and knows what failed. Starting fresh discards that and costs ~3-4× more tokens. Use \`task_id\` for retries and for asking the same subagent to plan its own diagnosis.
|
||||
|
||||
**Why no excuses:** the user requires every task to complete. Documenting a failure and moving on produces a partial plan that will fail Final Wave review. Verification is the gate. Push through it.
|
||||
|
||||
### 3.6 Loop Until Implementation Complete
|
||||
|
||||
|
||||
@@ -162,16 +162,15 @@ Read(".sisyphus/plans/{plan-name}.md")
|
||||
\`\`\`
|
||||
Count remaining **top-level task** checkboxes. Ignore nested verification/evidence checkboxes.
|
||||
|
||||
### 3.5 Handle Failures
|
||||
### 3.5 Handle Failures (NEVER GIVE UP)
|
||||
|
||||
**CRITICAL: Use \`task_id\` for retries.**
|
||||
|
||||
\`\`\`typescript
|
||||
task(task_id="ses_xyz789", load_skills=[...], prompt="FAILED: {error}. Fix by: {instruction}")
|
||||
task(task_id="ses_xyz789", load_skills=[...], prompt="FAILED: {actual error}. Diagnosis: {what you observed}. Fix by: {instruction}")
|
||||
\`\`\`
|
||||
|
||||
- Maximum 3 retries per task
|
||||
- If blocked: document and continue to next independent task
|
||||
**Failure is never an excuse to stop or skip.** A subagent reporting success when verification fails is wrong, not "experiencing a false positive". "False positive" is not a valid reason in this codebase. There is no retry cap. Diagnose, attach a plan, resume the same session until verification passes. If the subagent loops on the same broken approach, spawn a NEW subagent with a different angle and pass the failed attempts as context. Never move on with a task unverified.
|
||||
|
||||
### 3.6 Loop Until Implementation Complete
|
||||
|
||||
|
||||
@@ -125,13 +125,13 @@ Read(".sisyphus/plans/{plan-name}.md")
|
||||
\`\`\`
|
||||
Count remaining **top-level task** checkboxes (ignore nested verification/evidence checkboxes). Ground truth.
|
||||
|
||||
### 3.5 Handle Failures (USE task_id)
|
||||
### 3.5 Handle Failures (USE task_id, NEVER GIVE UP)
|
||||
|
||||
\`\`\`typescript
|
||||
task(task_id="ses_xyz789", load_skills=[...], prompt="FAILED: {error}. Fix by: {instruction}")
|
||||
task(task_id="ses_xyz789", load_skills=[...], prompt="FAILED: {actual error}. Diagnosis: {what you observed}. Fix by: {instruction}")
|
||||
\`\`\`
|
||||
|
||||
Maximum 3 retries on the same session. Then document and move to next independent task.
|
||||
**Failure is never an excuse to stop or skip.** A subagent reporting success when verification fails is wrong, not "experiencing a false positive". "False positive" is not a valid reason in this codebase. There is no retry cap. Diagnose, attach a plan, resume the same session until verification passes. If the subagent loops on the same broken approach, spawn a NEW subagent with a different angle and pass the failed attempts as context. Never move on with a task unverified.
|
||||
|
||||
### 3.6 Loop Until Implementation Complete
|
||||
|
||||
|
||||
@@ -127,13 +127,13 @@ Count remaining **top-level task** checkboxes. Ignore nested verification/eviden
|
||||
|
||||
**If verification fails**: resume the SAME session via \`task_id\`. Do not start fresh.
|
||||
|
||||
### 3.5 Handle Failures (USE task_id)
|
||||
### 3.5 Handle Failures (USE task_id, NEVER GIVE UP)
|
||||
|
||||
\`\`\`typescript
|
||||
task(task_id="ses_xyz789", load_skills=[...], prompt="FAILED: {error}. Fix by: {specific instruction}")
|
||||
task(task_id="ses_xyz789", load_skills=[...], prompt="FAILED: {actual error}. Diagnosis: {what you observed}. Fix by: {specific instruction}")
|
||||
\`\`\`
|
||||
|
||||
Maximum 3 retries on the same session. Then document and move on.
|
||||
**Failure is never an excuse to stop or skip.** A subagent reporting success when verification fails is wrong, not "experiencing a false positive". "False positive" is not a valid reason in this codebase. There is no retry cap. Diagnose, attach a plan, resume the same session until verification passes. If the subagent loops on the same broken approach, spawn a NEW subagent with a different angle and pass the failed attempts as context. Never move on with a task unverified.
|
||||
|
||||
### 3.6 Loop Until Implementation Complete
|
||||
|
||||
|
||||
@@ -134,17 +134,19 @@ Count remaining **top-level task** checkboxes. Ignore nested verification/eviden
|
||||
task(task_id="ses_xyz789", load_skills=[...], prompt="Verification failed: {actual error}. Fix.")
|
||||
\`\`\`
|
||||
|
||||
### 3.5 Handle Failures (USE task_id)
|
||||
### 3.5 Handle Failures (USE task_id, NEVER GIVE UP)
|
||||
|
||||
Every \`task()\` output includes a task_id. STORE IT.
|
||||
|
||||
If task fails:
|
||||
1. Identify what went wrong
|
||||
2. Resume the SAME session via \`task_id\` (subagent already has full context)
|
||||
3. Maximum 3 retry attempts on the same session
|
||||
4. If still blocked: document and continue to independent tasks
|
||||
**Failure is never an excuse to stop or skip.** A subagent that reports success when verification fails is wrong, not "experiencing a false positive". "False positive" is not a valid reason in this codebase. If verification fails, the work is unfinished. There is no retry cap.
|
||||
|
||||
**NEVER start fresh on failures** — wipes accumulated context, costs ~3-4× more tokens.
|
||||
When a task fails:
|
||||
1. Diagnose what actually broke. Read the error, read the file, do not guess.
|
||||
2. Resume the SAME session via \`task_id\` (subagent already has full context).
|
||||
3. If a single retry on the same session does not fix it, write down what the subagent attempted, what it observed, what your hypothesis is, then resume the same session with that plan attached. Iterate until verification passes.
|
||||
4. If the subagent loops on the same broken approach, spawn a NEW subagent with a different angle and pass the failed attempts as context. Stay on the same plan task; never move on with that task unverified.
|
||||
|
||||
**NEVER start fresh on every retry**. That wipes accumulated context and costs ~3-4× more tokens. Reserve fresh sessions for a deliberately different angle.
|
||||
|
||||
### 3.6 Loop Until Implementation Complete
|
||||
|
||||
|
||||
@@ -186,6 +186,36 @@ After EVERY verified task() completion, you MUST:
|
||||
This ensures accurate progress tracking. Skip this and you lose visibility into what remains.
|
||||
</post_delegation_rule>`
|
||||
|
||||
const ATLAS_BOULDER_COMPLETION_RESPONSE = `<boulder_completion_response>
|
||||
## When the Boulder-Complete Nudge Arrives
|
||||
|
||||
The system injects ONE nudge into your session when every top-level checkbox in the active plan flips to \`- [x]\`. That nudge carries the total elapsed time and a per-task breakdown for the active boulder. Recognize it by the phrase "BOULDER COMPLETE" near the top of the injected message.
|
||||
|
||||
When you see that nudge:
|
||||
|
||||
1. In your next turn, print the final orchestration summary using this exact shape:
|
||||
|
||||
\`\`\`
|
||||
ORCHESTRATION COMPLETE
|
||||
|
||||
PLAN: {plan-name}
|
||||
TOTAL ELAPSED: {total elapsed, human readable}
|
||||
TASKS COMPLETED: {N}/{N}
|
||||
|
||||
PER-TASK ELAPSED:
|
||||
- {label} {title}: {elapsed}
|
||||
- {label} {title}: {elapsed}
|
||||
|
||||
FINAL WAVE: F1 [...] | F2 [...] | F3 [...] | F4 [...]
|
||||
\`\`\`
|
||||
|
||||
2. Confirm via your tools that the active work in \`.sisyphus/boulder.json\` now has \`status: "completed"\` and \`elapsed_ms\` populated. The hook calls \`completeBoulder()\` for you; you are reading state, not writing it.
|
||||
|
||||
3. Mark the \`pass-final-wave\` todo as \`completed\` only after the Final Verification Wave reviewers all APPROVE. If the wave has not run yet, run it now in parallel; the boulder-complete nudge does not bypass it.
|
||||
|
||||
The nudge fires at most once per work. If you missed it (compaction, session restart), read \`boulder.json\` yourself, compute the same summary from \`started_at\`, \`ended_at\`, and \`task_sessions[*].elapsed_ms\`, and print it.
|
||||
</boulder_completion_response>`
|
||||
|
||||
export function buildAtlasPrompt(sections: AtlasPromptSections): string {
|
||||
const addendum = sections.parallelAddendum.trim().length > 0 ? `\n\n${sections.parallelAddendum}` : ""
|
||||
|
||||
@@ -210,5 +240,7 @@ ${sections.boundaries}
|
||||
${sections.criticalRules}
|
||||
|
||||
${ATLAS_POST_DELEGATION_RULE}
|
||||
|
||||
${ATLAS_BOULDER_COMPLETION_RESPONSE}
|
||||
`
|
||||
}
|
||||
|
||||
@@ -205,14 +205,19 @@ CLEARANCE CHECKLIST (ALL must be YES to auto-transition):
|
||||
\`\`\`typescript
|
||||
TodoWrite([
|
||||
{ id: "plan-1", content: "Consult Metis for gap analysis", status: "pending", priority: "high" },
|
||||
{ id: "plan-1b", content: "Oracle verification: phase 1 (interview completeness, scope, test strategy)", status: "pending", priority: "high" },
|
||||
{ id: "plan-2", content: "Generate plan to .sisyphus/plans/{name}.md", status: "pending", priority: "high" },
|
||||
{ id: "plan-2b", content: "Oracle verification: phase 2 (plan compliance, parallelism, acceptance criteria)", status: "pending", priority: "high" },
|
||||
{ id: "plan-3", content: "Self-review: classify gaps", status: "pending", priority: "high" },
|
||||
{ id: "plan-4", content: "Present summary with decisions needed", status: "pending", priority: "high" },
|
||||
{ id: "plan-5", content: "Ask about high accuracy mode (Momus)", status: "pending", priority: "high" },
|
||||
{ id: "plan-5b", content: "Oracle verification: phase 3 (plan readiness for execution)", status: "pending", priority: "high" },
|
||||
{ id: "plan-6", content: "Cleanup draft, guide to /start-work", status: "pending", priority: "medium" }
|
||||
])
|
||||
\`\`\`
|
||||
|
||||
Oracle verification gates (plan-1b, plan-2b, plan-5b) are blocking. Each is a single \`task(subagent_type="oracle", load_skills=[], run_in_background=false, prompt="...")\` invocation that must return \`VERDICT: GO\` before the workflow continues. \`NO-GO\` is a directive to fix the cited issues and rerun on the same Oracle session via \`task_id\`, not a license to skip.
|
||||
|
||||
### Step 2: Consult Metis (MANDATORY)
|
||||
|
||||
\`\`\`typescript
|
||||
|
||||
@@ -192,14 +192,19 @@ CLEARANCE CHECKLIST (ALL must be YES to auto-transition):
|
||||
\`\`\`typescript
|
||||
TodoWrite([
|
||||
{ id: "plan-1", content: "Consult Metis for gap analysis", status: "pending", priority: "high" },
|
||||
{ id: "plan-1b", content: "Oracle verification: phase 1 (interview completeness, scope, test strategy)", status: "pending", priority: "high" },
|
||||
{ id: "plan-2", content: "Generate plan to .sisyphus/plans/{name}.md", status: "pending", priority: "high" },
|
||||
{ id: "plan-2b", content: "Oracle verification: phase 2 (plan compliance, parallelism, acceptance criteria)", status: "pending", priority: "high" },
|
||||
{ id: "plan-3", content: "Self-review: classify gaps (critical/minor/ambiguous)", status: "pending", priority: "high" },
|
||||
{ id: "plan-4", content: "Present summary with decisions needed", status: "pending", priority: "high" },
|
||||
{ id: "plan-5", content: "Ask about high accuracy mode (Momus review)", status: "pending", priority: "high" },
|
||||
{ id: "plan-5b", content: "Oracle verification: phase 3 (plan readiness for execution)", status: "pending", priority: "high" },
|
||||
{ id: "plan-6", content: "Cleanup draft, guide to /start-work", status: "pending", priority: "medium" }
|
||||
])
|
||||
\`\`\`
|
||||
|
||||
Oracle verification gates (plan-1b, plan-2b, plan-5b) are blocking. Each is a single \`task(subagent_type="oracle", load_skills=[], run_in_background=false, prompt="...")\` invocation that must return \`VERDICT: GO\` before the workflow continues. \`NO-GO\` is a directive to fix the cited issues and rerun on the same Oracle session via \`task_id\`, not a license to skip.
|
||||
|
||||
### Step 2: Consult Metis (MANDATORY)
|
||||
|
||||
\`\`\`typescript
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { PROMETHEUS_PLAN_GENERATION } from "./plan-generation"
|
||||
|
||||
describe("PROMETHEUS_PLAN_GENERATION oracle phase gates", () => {
|
||||
describe("#given Prometheus plan generation prompt", () => {
|
||||
describe("#when inspecting the registered todo list", () => {
|
||||
it("#then includes plan-1b oracle verification after Metis", () => {
|
||||
expect(PROMETHEUS_PLAN_GENERATION).toContain(`id: "plan-1b"`)
|
||||
expect(PROMETHEUS_PLAN_GENERATION).toMatch(/plan-1b[^\n]*Oracle verification/i)
|
||||
})
|
||||
|
||||
it("#then includes plan-2b oracle verification after plan generation", () => {
|
||||
expect(PROMETHEUS_PLAN_GENERATION).toContain(`id: "plan-2b"`)
|
||||
expect(PROMETHEUS_PLAN_GENERATION).toMatch(/plan-2b[^\n]*Oracle verification/i)
|
||||
})
|
||||
|
||||
it("#then includes plan-6b oracle verification before handoff", () => {
|
||||
expect(PROMETHEUS_PLAN_GENERATION).toContain(`id: "plan-6b"`)
|
||||
expect(PROMETHEUS_PLAN_GENERATION).toMatch(/plan-6b[^\n]*Oracle verification/i)
|
||||
})
|
||||
|
||||
it("#then preserves the existing plan-1 through plan-8 todos", () => {
|
||||
for (const id of ["plan-1", "plan-2", "plan-3", "plan-4", "plan-5", "plan-6", "plan-7", "plan-8"]) {
|
||||
expect(PROMETHEUS_PLAN_GENERATION, `${id} todo must remain`).toContain(`id: "${id}"`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when describing oracle invocations", () => {
|
||||
it("#then provides concrete task() calls for all three phase gates", () => {
|
||||
const oracleInvocations = PROMETHEUS_PLAN_GENERATION.match(/subagent_type="oracle"/g) ?? []
|
||||
expect(oracleInvocations.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it("#then names a dedicated Oracle Verification section", () => {
|
||||
expect(PROMETHEUS_PLAN_GENERATION).toContain("Oracle Verification (Phase Gates)")
|
||||
})
|
||||
|
||||
it("#then declares each gate is blocking with GO/NO-GO verdict format", () => {
|
||||
expect(PROMETHEUS_PLAN_GENERATION).toContain("VERDICT: GO/NO-GO")
|
||||
expect(PROMETHEUS_PLAN_GENERATION.toLowerCase()).toContain("blocking")
|
||||
})
|
||||
|
||||
it("#then forbids skipping the gate on NO-GO", () => {
|
||||
const lower = PROMETHEUS_PLAN_GENERATION.toLowerCase()
|
||||
expect(lower).toMatch(/no-go is not an excuse to skip|fix the cited issues/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when describing the updated workflow", () => {
|
||||
it("#then orders the gates after their respective phases", () => {
|
||||
const idxPlan1b = PROMETHEUS_PLAN_GENERATION.indexOf(`id: "plan-1b"`)
|
||||
const idxPlan2 = PROMETHEUS_PLAN_GENERATION.indexOf(`id: "plan-2"`)
|
||||
const idxPlan2b = PROMETHEUS_PLAN_GENERATION.indexOf(`id: "plan-2b"`)
|
||||
const idxPlan6 = PROMETHEUS_PLAN_GENERATION.indexOf(`id: "plan-6"`)
|
||||
const idxPlan6b = PROMETHEUS_PLAN_GENERATION.indexOf(`id: "plan-6b"`)
|
||||
|
||||
expect(idxPlan1b, "plan-1b must precede plan-2 (gate runs before next phase)").toBeLessThan(idxPlan2)
|
||||
expect(idxPlan2b, "plan-2b must follow plan-2").toBeGreaterThan(idxPlan2)
|
||||
expect(idxPlan6b, "plan-6b must follow plan-6").toBeGreaterThan(idxPlan6)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -27,11 +27,14 @@ export const PROMETHEUS_PLAN_GENERATION = `# PHASE 2: PLAN GENERATION (Auto-Tran
|
||||
// IMMEDIATELY upon trigger detection - NO EXCEPTIONS
|
||||
todoWrite([
|
||||
{ id: "plan-1", content: "Consult Metis for gap analysis (auto-proceed)", status: "pending", priority: "high" },
|
||||
{ id: "plan-1b", content: "Oracle verification: phase 1 (interview completeness, requirements clarity, scope boundaries)", status: "pending", priority: "high" },
|
||||
{ id: "plan-2", content: "Generate work plan to .sisyphus/plans/{name}.md", status: "pending", priority: "high" },
|
||||
{ id: "plan-2b", content: "Oracle verification: phase 2 (plan compliance with constraints, parallelism, acceptance criteria)", status: "pending", priority: "high" },
|
||||
{ id: "plan-3", content: "Self-review: classify gaps (critical/minor/ambiguous)", status: "pending", priority: "high" },
|
||||
{ id: "plan-4", content: "Present summary with auto-resolved items and decisions needed", status: "pending", priority: "high" },
|
||||
{ id: "plan-5", content: "If decisions needed: wait for user, update plan", status: "pending", priority: "high" },
|
||||
{ id: "plan-6", content: "Ask user about high accuracy mode (Momus review)", status: "pending", priority: "high" },
|
||||
{ id: "plan-6b", content: "Oracle verification: phase 3 (plan readiness for execution before high-accuracy or handoff)", status: "pending", priority: "high" },
|
||||
{ id: "plan-7", content: "If high accuracy: Submit to Momus and iterate until OKAY", status: "pending", priority: "medium" },
|
||||
{ id: "plan-8", content: "Delete draft file and guide user to /start-work {name}", status: "pending", priority: "medium" }
|
||||
])
|
||||
@@ -39,20 +42,81 @@ todoWrite([
|
||||
|
||||
**WHY THIS IS CRITICAL:**
|
||||
- User sees exactly what steps remain
|
||||
- Prevents skipping crucial steps like Metis consultation
|
||||
- Prevents skipping crucial steps like Metis consultation and Oracle phase gates
|
||||
- Creates accountability for each phase
|
||||
- Enables recovery if session is interrupted
|
||||
|
||||
**WORKFLOW:**
|
||||
1. Trigger detected → **IMMEDIATELY** TodoWrite (plan-1 through plan-8)
|
||||
1. Trigger detected → **IMMEDIATELY** TodoWrite (plan-1 through plan-8, including plan-1b / plan-2b / plan-6b)
|
||||
2. Mark plan-1 as \`in_progress\` → Consult Metis (auto-proceed, no questions)
|
||||
3. Mark plan-2 as \`in_progress\` → Generate plan immediately
|
||||
4. Mark plan-3 as \`in_progress\` → Self-review and classify gaps
|
||||
5. Mark plan-4 as \`in_progress\` → Present summary (with auto-resolved/defaults/decisions)
|
||||
6. Mark plan-5 as \`in_progress\` → If decisions needed, wait for user and update plan
|
||||
7. Mark plan-6 as \`in_progress\` → Ask high accuracy question
|
||||
8. Continue marking todos as you progress
|
||||
9. NEVER skip a todo. NEVER proceed without updating status.
|
||||
3. Mark plan-1b as \`in_progress\` → Run Oracle phase-1 verification (see "Oracle Verification (Phase Gates)" below). Must produce VERDICT: GO before continuing.
|
||||
4. Mark plan-2 as \`in_progress\` → Generate plan immediately
|
||||
5. Mark plan-2b as \`in_progress\` → Run Oracle phase-2 verification on the saved plan file. Must produce VERDICT: GO before continuing.
|
||||
6. Mark plan-3 as \`in_progress\` → Self-review and classify gaps
|
||||
7. Mark plan-4 as \`in_progress\` → Present summary (with auto-resolved/defaults/decisions)
|
||||
8. Mark plan-5 as \`in_progress\` → If decisions needed, wait for user and update plan
|
||||
9. Mark plan-6 as \`in_progress\` → Ask high accuracy question
|
||||
10. Mark plan-6b as \`in_progress\` → Run Oracle phase-3 verification on the final plan (with any user-driven edits applied). Must produce VERDICT: GO before handoff.
|
||||
11. Continue marking todos as you progress
|
||||
12. NEVER skip a todo. NEVER proceed without updating status. **Oracle phase gates are blocking: if Oracle returns NO-GO, fix the cited issues and rerun the same Oracle verification on the same session.**
|
||||
|
||||
## Oracle Verification (Phase Gates)
|
||||
|
||||
Three blocking phase gates use the Oracle agent (read-only consultant). Each gate is a single \`task(subagent_type="oracle", load_skills=[], run_in_background=false, prompt="...")\` invocation. The Oracle must return VERDICT: GO before the workflow continues. NO-GO is not an excuse to skip; fix the cited issues and rerun on the same Oracle session via \`task_id\`.
|
||||
|
||||
### plan-1b: phase 1 verification (after Metis, before plan generation)
|
||||
|
||||
\`\`\`typescript
|
||||
task(
|
||||
subagent_type="oracle",
|
||||
load_skills=[],
|
||||
run_in_background=false,
|
||||
prompt=\`Verify Prometheus phase 1 (interview) is complete and consistent. Read the draft at .sisyphus/drafts/{name}.md and Metis's findings recorded in this session. Confirm:
|
||||
1. Core objective is unambiguous (one sentence, no hidden alternates).
|
||||
2. Scope IN / Scope OUT are both explicit.
|
||||
3. Test strategy is decided (TDD / tests-after / none + agent QA).
|
||||
4. No outstanding user questions remain.
|
||||
5. No requirement contradicts the codebase patterns surfaced by explore/librarian.
|
||||
Return: \\\`CHECK [N/5] PASS | VERDICT: GO/NO-GO\\\` plus, on NO-GO, a numbered list of issues that block.\`
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
### plan-2b: phase 2 verification (after plan generation, before self-review)
|
||||
|
||||
\`\`\`typescript
|
||||
task(
|
||||
subagent_type="oracle",
|
||||
load_skills=[],
|
||||
run_in_background=false,
|
||||
prompt=\`Verify Prometheus phase 2 (plan generation). Read .sisyphus/plans/{name}.md end to end. Confirm:
|
||||
1. Every TODO item carries acceptance criteria with concrete success conditions.
|
||||
2. Each task has a recommended agent profile and a Wave assignment.
|
||||
3. Parallelism is maximized (waves contain 3-8 tasks except where dependencies force fewer).
|
||||
4. Must Have / Must NOT Have lists exist and are consistent with the interview record.
|
||||
5. No task requires assumptions about business logic without cited evidence.
|
||||
6. Plan path is .sisyphus/plans/, not docs/ or plans/.
|
||||
Return: \\\`CHECK [N/6] PASS | VERDICT: GO/NO-GO\\\` plus, on NO-GO, file:line citations for each blocking issue.\`
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
### plan-6b: phase 3 verification (after high-accuracy decision, before handoff)
|
||||
|
||||
\`\`\`typescript
|
||||
task(
|
||||
subagent_type="oracle",
|
||||
load_skills=[],
|
||||
run_in_background=false,
|
||||
prompt=\`Verify the plan at .sisyphus/plans/{name}.md is ready for execution by /start-work. Confirm:
|
||||
1. Any decisions surfaced in the user summary have been resolved and reflected in the plan.
|
||||
2. The final-wave reviewer set (F1-F4) is present and addressable.
|
||||
3. Commit strategy and verification commands are stated.
|
||||
4. The plan is internally consistent after the most recent edits.
|
||||
5. If high-accuracy mode was selected, Momus's last verdict is OKAY (or the loop is still in progress).
|
||||
Return: \\\`CHECK [N/5] PASS | VERDICT: GO/NO-GO\\\` plus, on NO-GO, what to fix.\`
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
**Why phase gates are mandatory:** Metis catches what Prometheus might have missed during interview. Oracle catches what Prometheus might be wrong about. Both run before code is touched. NO-GO is a directive to fix, not a license to abandon the gate.
|
||||
|
||||
## Pre-Generation: Metis Consultation (MANDATORY)
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import { afterEach, describe, expect, it } from "bun:test"
|
||||
|
||||
import { boulder } from "./boulder"
|
||||
|
||||
function createTempDirectory(): string {
|
||||
return mkdtempSync(join(tmpdir(), "omo-boulder-cli-"))
|
||||
}
|
||||
|
||||
function seedPlanAndState(directory: string): void {
|
||||
const planDirectory = join(directory, ".sisyphus", "plans")
|
||||
mkdirSync(planDirectory, { recursive: true })
|
||||
|
||||
const planAPath = join(planDirectory, "alpha.md")
|
||||
const planBPath = join(planDirectory, "beta.md")
|
||||
|
||||
writeFileSync(
|
||||
planAPath,
|
||||
[
|
||||
"## TODOs",
|
||||
"- [x] 1. Alpha task done",
|
||||
"- [ ] 2. Alpha task running",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
)
|
||||
writeFileSync(
|
||||
planBPath,
|
||||
[
|
||||
"## TODOs",
|
||||
"- [x] 1. Beta task done",
|
||||
"- [x] 2. Beta task done too",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
)
|
||||
|
||||
const boulderDirectory = join(directory, ".sisyphus")
|
||||
mkdirSync(boulderDirectory, { recursive: true })
|
||||
|
||||
writeFileSync(
|
||||
join(boulderDirectory, "boulder.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
schema_version: 2,
|
||||
active_work_id: "work-alpha",
|
||||
active_plan: planAPath,
|
||||
started_at: "2026-05-10T00:00:00.000Z",
|
||||
ended_at: "2026-05-10T00:30:00.000Z",
|
||||
elapsed_ms: 1_800_000,
|
||||
status: "active",
|
||||
updated_at: "2026-05-10T00:30:00.000Z",
|
||||
session_ids: ["ses-1", "ses-2"],
|
||||
plan_name: "alpha",
|
||||
task_sessions: {
|
||||
"todo:2": {
|
||||
task_key: "todo:2",
|
||||
task_label: "2",
|
||||
task_title: "Alpha task running",
|
||||
session_id: "ses-2",
|
||||
elapsed_ms: 60000,
|
||||
status: "running",
|
||||
updated_at: "2026-05-10T00:30:00.000Z",
|
||||
},
|
||||
},
|
||||
works: {
|
||||
"work-alpha": {
|
||||
work_id: "work-alpha",
|
||||
active_plan: planAPath,
|
||||
plan_name: "alpha",
|
||||
status: "active",
|
||||
started_at: "2026-05-10T00:00:00.000Z",
|
||||
elapsed_ms: 1_800_000,
|
||||
updated_at: "2026-05-10T00:30:00.000Z",
|
||||
session_ids: ["ses-1", "ses-2"],
|
||||
task_sessions: {
|
||||
"todo:2": {
|
||||
task_key: "todo:2",
|
||||
task_label: "2",
|
||||
task_title: "Alpha task running",
|
||||
session_id: "ses-2",
|
||||
elapsed_ms: 60000,
|
||||
status: "running",
|
||||
updated_at: "2026-05-10T00:30:00.000Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
"work-beta": {
|
||||
work_id: "work-beta",
|
||||
active_plan: planBPath,
|
||||
plan_name: "beta",
|
||||
status: "completed",
|
||||
started_at: "2026-05-10T01:00:00.000Z",
|
||||
ended_at: "2026-05-10T01:10:00.000Z",
|
||||
elapsed_ms: 600000,
|
||||
updated_at: "2026-05-10T01:10:00.000Z",
|
||||
session_ids: ["ses-3"],
|
||||
task_sessions: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
)
|
||||
}
|
||||
|
||||
describe("boulder command", () => {
|
||||
const createdDirectories: string[] = []
|
||||
const outputRestores: Array<() => void> = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of createdDirectories) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
createdDirectories.length = 0
|
||||
for (const restoreOutput of outputRestores) {
|
||||
restoreOutput()
|
||||
}
|
||||
outputRestores.length = 0
|
||||
})
|
||||
|
||||
function captureOutput(target: "stdout" | "stderr", sink: { value: string }): void {
|
||||
const originalWrite = process[target].write
|
||||
process[target].write = ((chunk: string | Uint8Array) => {
|
||||
sink.value += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf-8")
|
||||
return true
|
||||
}) as typeof process.stdout.write
|
||||
|
||||
outputRestores.push(() => {
|
||||
process[target].write = originalWrite
|
||||
})
|
||||
}
|
||||
|
||||
it("prints multi-work text mode with plan names and percentages", async () => {
|
||||
const directory = createTempDirectory()
|
||||
createdDirectories.push(directory)
|
||||
seedPlanAndState(directory)
|
||||
|
||||
const stdout = { value: "" }
|
||||
const stderr = { value: "" }
|
||||
captureOutput("stdout", stdout)
|
||||
captureOutput("stderr", stderr)
|
||||
|
||||
const exitCode = await boulder({ directory })
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(stderr.value).toBe("")
|
||||
expect(stdout.value).toContain("plan: alpha")
|
||||
expect(stdout.value).toContain("plan: beta")
|
||||
expect(stdout.value).toContain("progress: 50% (1/2)")
|
||||
expect(stdout.value).toContain("progress: 100% (2/2)")
|
||||
expect(stdout.value).toContain("elapsed:")
|
||||
})
|
||||
|
||||
it("prints json mode with expected fields", async () => {
|
||||
const directory = createTempDirectory()
|
||||
createdDirectories.push(directory)
|
||||
seedPlanAndState(directory)
|
||||
|
||||
const stdout = { value: "" }
|
||||
captureOutput("stdout", stdout)
|
||||
|
||||
const exitCode = await boulder({ directory, json: true })
|
||||
expect(exitCode).toBe(0)
|
||||
|
||||
const parsed = JSON.parse(stdout.value)
|
||||
expect(parsed.works).toHaveLength(2)
|
||||
expect(parsed.works[0]).toHaveProperty("work_id")
|
||||
expect(parsed.works[0]).toHaveProperty("percentage")
|
||||
expect(parsed.works[0]).toHaveProperty("remaining_tasks")
|
||||
})
|
||||
|
||||
it("returns 1 when boulder state does not exist", async () => {
|
||||
const directory = createTempDirectory()
|
||||
createdDirectories.push(directory)
|
||||
|
||||
const stderr = { value: "" }
|
||||
captureOutput("stderr", stderr)
|
||||
|
||||
const exitCode = await boulder({ directory })
|
||||
expect(exitCode).toBe(1)
|
||||
expect(stderr.value).toContain("No boulder state found")
|
||||
})
|
||||
|
||||
it("returns 1 when workId filter matches none", async () => {
|
||||
const directory = createTempDirectory()
|
||||
createdDirectories.push(directory)
|
||||
seedPlanAndState(directory)
|
||||
|
||||
const stderr = { value: "" }
|
||||
captureOutput("stderr", stderr)
|
||||
|
||||
const exitCode = await boulder({ directory, workId: "missing" })
|
||||
expect(exitCode).toBe(1)
|
||||
expect(stderr.value).toContain("No boulder state found")
|
||||
})
|
||||
|
||||
it("returns one work when workId filter matches", async () => {
|
||||
const directory = createTempDirectory()
|
||||
createdDirectories.push(directory)
|
||||
seedPlanAndState(directory)
|
||||
|
||||
const stdout = { value: "" }
|
||||
captureOutput("stdout", stdout)
|
||||
|
||||
const exitCode = await boulder({ directory, workId: "work-beta", json: true })
|
||||
expect(exitCode).toBe(0)
|
||||
|
||||
const parsed = JSON.parse(stdout.value)
|
||||
expect(parsed.works).toHaveLength(1)
|
||||
expect(parsed.works[0].work_id).toBe("work-beta")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import { existsSync } from "node:fs"
|
||||
|
||||
import {
|
||||
getBoulderFilePath,
|
||||
getBoulderWorks,
|
||||
getPlanProgress,
|
||||
readBoulderState,
|
||||
readCurrentTopLevelTask,
|
||||
resolveBoulderPlanPathForWork,
|
||||
} from "../../features/boulder-state"
|
||||
import type { BoulderWorkState } from "../../features/boulder-state"
|
||||
import {
|
||||
formatJsonOutput,
|
||||
formatNoBoulderMessage,
|
||||
formatReadErrorMessage,
|
||||
formatTextOutput,
|
||||
} from "./formatter"
|
||||
import type { BoulderCliResult, BoulderCliWork, BoulderOptions } from "./types"
|
||||
|
||||
function formatDurationHuman(durationMs: number): string {
|
||||
if (durationMs < 1000) {
|
||||
return `${durationMs}ms`
|
||||
}
|
||||
|
||||
const totalSeconds = Math.floor(durationMs / 1000)
|
||||
const seconds = totalSeconds % 60
|
||||
const totalMinutes = Math.floor(totalSeconds / 60)
|
||||
const minutes = totalMinutes % 60
|
||||
const hours = Math.floor(totalMinutes / 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${seconds}s`
|
||||
}
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds}s`
|
||||
}
|
||||
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
function getElapsedMs(work: BoulderWorkState): number | undefined {
|
||||
if (work.elapsed_ms !== undefined) {
|
||||
return work.elapsed_ms
|
||||
}
|
||||
|
||||
const startedAtMs = Date.parse(work.started_at)
|
||||
if (Number.isNaN(startedAtMs)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const endedAtMs = work.ended_at ? Date.parse(work.ended_at) : Date.now()
|
||||
if (Number.isNaN(endedAtMs)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return Math.max(0, endedAtMs - startedAtMs)
|
||||
}
|
||||
|
||||
function buildCliWork(directory: string, work: BoulderWorkState): BoulderCliWork {
|
||||
const planPath = resolveBoulderPlanPathForWork(directory, work)
|
||||
const progress = getPlanProgress(planPath)
|
||||
const elapsedMs = getElapsedMs(work)
|
||||
const currentTask = readCurrentTopLevelTask(planPath)
|
||||
const taskSession = currentTask ? work.task_sessions?.[currentTask.key] : undefined
|
||||
|
||||
let currentTaskElapsedHuman: string | undefined
|
||||
if (taskSession?.elapsed_ms !== undefined) {
|
||||
currentTaskElapsedHuman = formatDurationHuman(taskSession.elapsed_ms)
|
||||
} else if (taskSession?.started_at) {
|
||||
const startedAtMs = Date.parse(taskSession.started_at)
|
||||
if (!Number.isNaN(startedAtMs)) {
|
||||
currentTaskElapsedHuman = formatDurationHuman(Math.max(0, Date.now() - startedAtMs))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
work_id: work.work_id,
|
||||
plan_name: work.plan_name,
|
||||
active_plan: work.active_plan,
|
||||
worktree_path: work.worktree_path,
|
||||
status: work.status ?? "active",
|
||||
started_at: work.started_at,
|
||||
ended_at: work.ended_at,
|
||||
elapsed_ms: elapsedMs,
|
||||
elapsed_human: elapsedMs !== undefined ? formatDurationHuman(elapsedMs) : undefined,
|
||||
total_tasks: progress.total,
|
||||
completed_tasks: progress.completed,
|
||||
remaining_tasks: Math.max(0, progress.total - progress.completed),
|
||||
percentage: progress.total > 0
|
||||
? Math.round((progress.completed / progress.total) * 100)
|
||||
: 0,
|
||||
session_count: work.session_ids.length,
|
||||
current_task: currentTask
|
||||
? {
|
||||
task_key: currentTask.key,
|
||||
task_title: currentTask.title,
|
||||
elapsed_human: currentTaskElapsedHuman,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function boulder(options: BoulderOptions): Promise<number> {
|
||||
const directory = options.directory ?? process.cwd()
|
||||
const boulderFilePath = getBoulderFilePath(directory)
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
const message = existsSync(boulderFilePath)
|
||||
? formatReadErrorMessage(options.json)
|
||||
: formatNoBoulderMessage(options.json)
|
||||
|
||||
process.stderr.write(`${message}\n`)
|
||||
return existsSync(boulderFilePath) ? 2 : 1
|
||||
}
|
||||
|
||||
const works = getBoulderWorks(state)
|
||||
const filteredWorks = options.workId
|
||||
? works.filter((work) => work.work_id === options.workId)
|
||||
: works
|
||||
|
||||
if (filteredWorks.length === 0) {
|
||||
process.stderr.write(`${formatNoBoulderMessage(options.json)}\n`)
|
||||
return 1
|
||||
}
|
||||
|
||||
const cliWorks = filteredWorks.map((work) => buildCliWork(directory, work))
|
||||
const result: BoulderCliResult = { works: cliWorks }
|
||||
|
||||
const output = options.json
|
||||
? formatJsonOutput(result)
|
||||
: formatTextOutput(result)
|
||||
|
||||
process.stdout.write(`${output}\n`)
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
|
||||
import { stripAnsi } from "../doctor/format-shared"
|
||||
import { formatJsonOutput, formatTextOutput } from "./formatter"
|
||||
import type { BoulderCliResult } from "./types"
|
||||
|
||||
describe("boulder formatter", () => {
|
||||
it("renders text output with statuses and progress", () => {
|
||||
const result: BoulderCliResult = {
|
||||
works: [
|
||||
{
|
||||
work_id: "w1",
|
||||
plan_name: "alpha",
|
||||
active_plan: "/tmp/alpha.md",
|
||||
status: "active",
|
||||
started_at: "2026-05-10T00:00:00.000Z",
|
||||
elapsed_human: "30m 0s",
|
||||
total_tasks: 2,
|
||||
completed_tasks: 1,
|
||||
remaining_tasks: 1,
|
||||
percentage: 50,
|
||||
session_count: 2,
|
||||
current_task: {
|
||||
task_key: "todo:2",
|
||||
task_title: "Alpha task",
|
||||
elapsed_human: "1m 0s",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const textOutput = stripAnsi(formatTextOutput(result))
|
||||
expect(textOutput).toContain("boulder progress")
|
||||
expect(textOutput).toContain("plan: alpha")
|
||||
expect(textOutput).toContain("status: active")
|
||||
expect(textOutput).toContain("progress: 50% (1/2)")
|
||||
expect(textOutput).toContain("elapsed: 30m 0s")
|
||||
})
|
||||
|
||||
it("renders parseable json output", () => {
|
||||
const result: BoulderCliResult = {
|
||||
works: [
|
||||
{
|
||||
work_id: "w1",
|
||||
plan_name: "alpha",
|
||||
active_plan: "/tmp/alpha.md",
|
||||
status: "completed",
|
||||
started_at: "2026-05-10T00:00:00.000Z",
|
||||
ended_at: "2026-05-10T00:01:00.000Z",
|
||||
elapsed_ms: 60_000,
|
||||
total_tasks: 2,
|
||||
completed_tasks: 2,
|
||||
remaining_tasks: 0,
|
||||
percentage: 100,
|
||||
session_count: 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const jsonOutput = formatJsonOutput(result)
|
||||
expect(JSON.parse(jsonOutput)).toEqual(result)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import color from "picocolors"
|
||||
|
||||
import type { BoulderWorkStatus } from "../../features/boulder-state"
|
||||
import type { BoulderCliResult, BoulderCliWork } from "./types"
|
||||
|
||||
function colorizeStatus(status: BoulderWorkStatus): string {
|
||||
if (status === "active") {
|
||||
return color.cyan(status)
|
||||
}
|
||||
|
||||
if (status === "completed") {
|
||||
return color.green(status)
|
||||
}
|
||||
|
||||
if (status === "paused") {
|
||||
return color.yellow(status)
|
||||
}
|
||||
|
||||
return color.red(status)
|
||||
}
|
||||
|
||||
function formatCurrentTask(work: BoulderCliWork): string {
|
||||
if (!work.current_task) {
|
||||
return "-"
|
||||
}
|
||||
|
||||
const elapsed = work.current_task.elapsed_human
|
||||
? ` (${work.current_task.elapsed_human})`
|
||||
: ""
|
||||
return `${work.current_task.task_title}${elapsed}`
|
||||
}
|
||||
|
||||
function formatWorkBlock(work: BoulderCliWork): string {
|
||||
const elapsed = work.elapsed_human ?? "-"
|
||||
const progress = `${work.percentage}% (${work.completed_tasks}/${work.total_tasks})`
|
||||
|
||||
return [
|
||||
`plan: ${work.plan_name}`,
|
||||
`status: ${colorizeStatus(work.status)}`,
|
||||
`progress: ${progress}`,
|
||||
`elapsed: ${elapsed}`,
|
||||
`sessions: ${work.session_count}`,
|
||||
`current task: ${formatCurrentTask(work)}`,
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
export function formatTextOutput(result: BoulderCliResult): string {
|
||||
const separator = color.dim("----------------------------------------")
|
||||
const blocks = result.works.map((work) => formatWorkBlock(work))
|
||||
return ["boulder progress", ...blocks].join(`\n${separator}\n`)
|
||||
}
|
||||
|
||||
export function formatJsonOutput(result: BoulderCliResult): string {
|
||||
return JSON.stringify(result, null, 2)
|
||||
}
|
||||
|
||||
export function formatNoBoulderMessage(isJson: boolean | undefined): string {
|
||||
if (isJson) {
|
||||
return JSON.stringify({
|
||||
error: "No boulder state found.",
|
||||
})
|
||||
}
|
||||
|
||||
return "No boulder state found."
|
||||
}
|
||||
|
||||
export function formatReadErrorMessage(isJson: boolean | undefined): string {
|
||||
if (isJson) {
|
||||
return JSON.stringify({
|
||||
error: "Failed to read boulder state.",
|
||||
})
|
||||
}
|
||||
|
||||
return "Failed to read boulder state."
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { boulder } from "./boulder"
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { BoulderWorkStatus } from "../../features/boulder-state"
|
||||
|
||||
export interface BoulderOptions {
|
||||
directory?: string
|
||||
workId?: string
|
||||
json?: boolean
|
||||
}
|
||||
|
||||
export interface BoulderCliWork {
|
||||
work_id: string
|
||||
plan_name: string
|
||||
active_plan: string
|
||||
worktree_path?: string
|
||||
status: BoulderWorkStatus
|
||||
started_at: string
|
||||
ended_at?: string
|
||||
elapsed_human?: string
|
||||
elapsed_ms?: number
|
||||
total_tasks: number
|
||||
completed_tasks: number
|
||||
remaining_tasks: number
|
||||
percentage: number
|
||||
session_count: number
|
||||
current_task?: {
|
||||
task_key: string
|
||||
task_title: string
|
||||
elapsed_human?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface BoulderCliResult {
|
||||
works: BoulderCliWork[]
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { getLocalVersion } from "./get-local-version"
|
||||
import { doctor } from "./doctor"
|
||||
import { refreshModelCapabilities } from "./refresh-model-capabilities"
|
||||
import { createMcpOAuthCommand } from "./mcp-oauth"
|
||||
import { boulder } from "./boulder"
|
||||
import type { InstallArgs } from "./types"
|
||||
import type { RunOptions } from "./run"
|
||||
import type { GetLocalVersionOptions } from "./get-local-version/types"
|
||||
@@ -202,6 +203,21 @@ program
|
||||
console.log(`oh-my-opencode v${VERSION}`)
|
||||
})
|
||||
|
||||
program
|
||||
.command("boulder")
|
||||
.description("Show boulder progress, elapsed time, and per-task statistics")
|
||||
.option("-d, --directory <path>", "Working directory")
|
||||
.option("-w, --work-id <id>", "Filter to a specific work")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async (options) => {
|
||||
const exitCode = await boulder({
|
||||
directory: options.directory,
|
||||
workId: options.workId,
|
||||
json: options.json ?? false,
|
||||
})
|
||||
process.exit(exitCode)
|
||||
})
|
||||
|
||||
program.addCommand(createMcpOAuthCommand())
|
||||
|
||||
export function runCli(): void {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { formatDurationHuman } from "./format-duration"
|
||||
|
||||
describe("formatDurationHuman", () => {
|
||||
it("returns 0s for 0ms", () => {
|
||||
expect(formatDurationHuman(0)).toBe("0s")
|
||||
})
|
||||
|
||||
it("returns 0s for 999ms", () => {
|
||||
expect(formatDurationHuman(999)).toBe("0s")
|
||||
})
|
||||
|
||||
it("returns 1s for 1000ms", () => {
|
||||
expect(formatDurationHuman(1000)).toBe("1s")
|
||||
})
|
||||
|
||||
it("returns 1m 0s for 60_000ms", () => {
|
||||
expect(formatDurationHuman(60_000)).toBe("1m 0s")
|
||||
})
|
||||
|
||||
it("returns 1h 0m 0s for 3_600_000ms", () => {
|
||||
expect(formatDurationHuman(3_600_000)).toBe("1h 0m 0s")
|
||||
})
|
||||
|
||||
it("returns 1h 2m 3s for 3_723_456ms", () => {
|
||||
expect(formatDurationHuman(3_723_456)).toBe("1h 2m 3s")
|
||||
})
|
||||
|
||||
it("returns 24h 0m 0s for 86_400_000ms", () => {
|
||||
expect(formatDurationHuman(86_400_000)).toBe("24h 0m 0s")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
export function formatDurationHuman(milliseconds: number): string {
|
||||
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000))
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${seconds}s`
|
||||
}
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds}s`
|
||||
}
|
||||
|
||||
return `${seconds}s`
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export * from "./types"
|
||||
export * from "./constants"
|
||||
export * from "./storage"
|
||||
export * from "./top-level-task"
|
||||
export * from "./format-duration"
|
||||
|
||||
@@ -3,17 +3,31 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { dirname, join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import {
|
||||
addBoulderWork,
|
||||
appendSessionIdForWork,
|
||||
completeBoulder,
|
||||
endTaskTimer,
|
||||
getActiveWorks,
|
||||
getBoulderWorks,
|
||||
readBoulderState,
|
||||
writeBoulderState,
|
||||
appendSessionId,
|
||||
clearBoulderState,
|
||||
getWorkById,
|
||||
getWorkByPlanName,
|
||||
getWorkForSession,
|
||||
getWorkResumeOptions,
|
||||
getPlanProgress,
|
||||
getPlanName,
|
||||
createBoulderState,
|
||||
findPrometheusPlans,
|
||||
getTaskSessionState,
|
||||
resolveBoulderPlanPath,
|
||||
resolveBoulderPlanPathForWork,
|
||||
selectActiveWork,
|
||||
startTaskTimer,
|
||||
upsertTaskSessionState,
|
||||
upsertTaskSessionStateForWork,
|
||||
} from "./storage"
|
||||
import type { BoulderState } from "./types"
|
||||
import { readCurrentTopLevelTask } from "./top-level-task"
|
||||
@@ -39,6 +53,31 @@ describe("boulder-state", () => {
|
||||
})
|
||||
|
||||
describe("readBoulderState", () => {
|
||||
test("should preserve legacy boulder.json fields during round-trip", () => {
|
||||
// given
|
||||
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
|
||||
const legacyRawState = {
|
||||
active_plan: "/path/to/legacy-plan.md",
|
||||
started_at: "2026-01-01T00:00:00.000Z",
|
||||
session_ids: ["legacy-session"],
|
||||
plan_name: "legacy-plan",
|
||||
}
|
||||
writeFileSync(boulderFile, JSON.stringify(legacyRawState, null, 2), "utf-8")
|
||||
|
||||
// when
|
||||
const state = readBoulderState(TEST_DIR)
|
||||
expect(state).not.toBeNull()
|
||||
const writeSucceeded = writeBoulderState(TEST_DIR, state!)
|
||||
const roundTripState = readBoulderState(TEST_DIR)
|
||||
|
||||
// then
|
||||
expect(writeSucceeded).toBe(true)
|
||||
expect(roundTripState?.active_plan).toBe(legacyRawState.active_plan)
|
||||
expect(roundTripState?.started_at).toBe(legacyRawState.started_at)
|
||||
expect(roundTripState?.session_ids).toEqual(legacyRawState.session_ids)
|
||||
expect(roundTripState?.plan_name).toBe(legacyRawState.plan_name)
|
||||
})
|
||||
|
||||
test("should return null when no boulder.json exists", () => {
|
||||
// given - no boulder.json file
|
||||
// when
|
||||
@@ -387,6 +426,225 @@ describe("boulder-state", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("multi-work helpers", () => {
|
||||
test("should add second work and keep both active works", () => {
|
||||
// given
|
||||
const firstState = createBoulderState(
|
||||
join(TEST_DIR, ".sisyphus/plans/plan-a.md"),
|
||||
"session-a",
|
||||
"atlas",
|
||||
"/worktree-a",
|
||||
)
|
||||
writeBoulderState(TEST_DIR, firstState)
|
||||
const firstWorkId = firstState.active_work_id
|
||||
|
||||
// when
|
||||
const updatedState = addBoulderWork(TEST_DIR, {
|
||||
planPath: join(TEST_DIR, ".sisyphus/plans/plan-b.md"),
|
||||
sessionId: "session-b",
|
||||
agent: "atlas",
|
||||
worktreePath: "/worktree-b",
|
||||
})
|
||||
|
||||
// then
|
||||
expect(updatedState).not.toBeNull()
|
||||
const works = updatedState?.works ?? {}
|
||||
expect(Object.keys(works).length).toBe(2)
|
||||
expect(firstWorkId).toBeDefined()
|
||||
expect(works[firstWorkId!]).toBeDefined()
|
||||
expect(updatedState?.active_plan).toContain("plan-b.md")
|
||||
expect(getActiveWorks(TEST_DIR).length).toBe(2)
|
||||
})
|
||||
|
||||
test("should resolve work for session using updated_at tie-break", () => {
|
||||
// given
|
||||
const baseState = createBoulderState(
|
||||
join(TEST_DIR, ".sisyphus/plans/plan-a.md"),
|
||||
"session-a",
|
||||
)
|
||||
writeBoulderState(TEST_DIR, baseState)
|
||||
const stateWithSecond = addBoulderWork(TEST_DIR, {
|
||||
planPath: join(TEST_DIR, ".sisyphus/plans/plan-b.md"),
|
||||
sessionId: "session-b",
|
||||
})
|
||||
expect(stateWithSecond).not.toBeNull()
|
||||
|
||||
const workIds = Object.keys(stateWithSecond!.works ?? {})
|
||||
expect(workIds.length).toBe(2)
|
||||
const firstWorkId = workIds.find((workId) => (stateWithSecond!.works?.[workId]?.plan_name ?? "") === "plan-a")!
|
||||
const secondWorkId = workIds.find((workId) => (stateWithSecond!.works?.[workId]?.plan_name ?? "") === "plan-b")!
|
||||
|
||||
appendSessionIdForWork(TEST_DIR, secondWorkId, "session-a", "appended")
|
||||
appendSessionIdForWork(TEST_DIR, firstWorkId, "session-a", "appended")
|
||||
|
||||
// when
|
||||
const resolvedWork = getWorkForSession(TEST_DIR, "session-a")
|
||||
|
||||
// then
|
||||
expect(resolvedWork?.work_id).toBe(firstWorkId)
|
||||
})
|
||||
|
||||
test("should support selecting active work and read helpers", () => {
|
||||
// given
|
||||
const initialState = createBoulderState(join(TEST_DIR, ".sisyphus/plans/plan-a.md"), "session-a")
|
||||
writeBoulderState(TEST_DIR, initialState)
|
||||
const added = addBoulderWork(TEST_DIR, {
|
||||
planPath: join(TEST_DIR, ".sisyphus/plans/plan-b.md"),
|
||||
sessionId: "session-b",
|
||||
worktreePath: "/tmp/worktree-b",
|
||||
})
|
||||
expect(added).not.toBeNull()
|
||||
const firstWork = getWorkByPlanName(TEST_DIR, "plan-a")
|
||||
expect(firstWork).not.toBeNull()
|
||||
|
||||
// when
|
||||
const selected = selectActiveWork(TEST_DIR, firstWork!.work_id)
|
||||
const selectedById = getWorkById(TEST_DIR, firstWork!.work_id)
|
||||
const byPlanNameWithWorktree = getWorkByPlanName(TEST_DIR, "plan-b", { worktreePath: "/tmp/worktree-b" })
|
||||
const byPlanPath = resolveBoulderPlanPathForWork(TEST_DIR, firstWork!)
|
||||
const resumeOptions = getWorkResumeOptions(TEST_DIR)
|
||||
const worksFromState = getBoulderWorks(selected!)
|
||||
|
||||
// then
|
||||
expect(selected?.active_work_id).toBe(firstWork!.work_id)
|
||||
expect(selectedById?.work_id).toBe(firstWork!.work_id)
|
||||
expect(byPlanNameWithWorktree?.plan_name).toBe("plan-b")
|
||||
expect(byPlanPath.endsWith("plan-a.md")).toBe(true)
|
||||
expect(resumeOptions.length).toBe(2)
|
||||
expect(worksFromState.length).toBe(2)
|
||||
})
|
||||
|
||||
test("should upsert task session for specific work and keep first started_at", () => {
|
||||
// given
|
||||
const initialState = createBoulderState(join(TEST_DIR, ".sisyphus/plans/plan-a.md"), "session-a")
|
||||
writeBoulderState(TEST_DIR, initialState)
|
||||
const workId = initialState.active_work_id!
|
||||
|
||||
upsertTaskSessionStateForWork(TEST_DIR, workId, {
|
||||
taskKey: "todo:1",
|
||||
taskLabel: "1",
|
||||
taskTitle: "task one",
|
||||
sessionId: "task-session-a",
|
||||
})
|
||||
|
||||
const seededState = readBoulderState(TEST_DIR)!
|
||||
seededState.works![workId]!.task_sessions!["todo:1"]!.started_at = "2026-01-01T00:00:00.000Z"
|
||||
writeBoulderState(TEST_DIR, seededState)
|
||||
|
||||
// when
|
||||
const updated = upsertTaskSessionStateForWork(TEST_DIR, workId, {
|
||||
taskKey: "todo:1",
|
||||
taskLabel: "1",
|
||||
taskTitle: "task one",
|
||||
sessionId: "task-session-b",
|
||||
})
|
||||
|
||||
// then
|
||||
expect(updated).not.toBeNull()
|
||||
const taskSession = updated?.works?.[workId]?.task_sessions?.["todo:1"]
|
||||
expect(taskSession?.session_id).toBe("task-session-b")
|
||||
expect(taskSession?.started_at).toBe("2026-01-01T00:00:00.000Z")
|
||||
})
|
||||
})
|
||||
|
||||
describe("task timer and completion helpers", () => {
|
||||
test("should keep started_at stable when starting timer repeatedly", () => {
|
||||
// given
|
||||
const initialState = createBoulderState(join(TEST_DIR, ".sisyphus/plans/plan-a.md"), "session-a")
|
||||
writeBoulderState(TEST_DIR, initialState)
|
||||
const workId = initialState.active_work_id!
|
||||
|
||||
// when
|
||||
startTaskTimer(TEST_DIR, workId, {
|
||||
taskKey: "todo:1",
|
||||
taskLabel: "1",
|
||||
taskTitle: "task one",
|
||||
sessionId: "session-a",
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
})
|
||||
startTaskTimer(TEST_DIR, workId, {
|
||||
taskKey: "todo:1",
|
||||
taskLabel: "1",
|
||||
taskTitle: "task one",
|
||||
sessionId: "session-a",
|
||||
startedAt: "2026-01-02T00:00:00.000Z",
|
||||
})
|
||||
|
||||
// then
|
||||
const taskSession = readBoulderState(TEST_DIR)?.works?.[workId]?.task_sessions?.["todo:1"]
|
||||
expect(taskSession?.started_at).toBe("2026-01-01T00:00:00.000Z")
|
||||
expect(taskSession?.status).toBe("running")
|
||||
})
|
||||
|
||||
test("should compute elapsed_ms when ending task timer", () => {
|
||||
// given
|
||||
const initialState = createBoulderState(join(TEST_DIR, ".sisyphus/plans/plan-a.md"), "session-a")
|
||||
writeBoulderState(TEST_DIR, initialState)
|
||||
const workId = initialState.active_work_id!
|
||||
startTaskTimer(TEST_DIR, workId, {
|
||||
taskKey: "todo:1",
|
||||
taskLabel: "1",
|
||||
taskTitle: "task one",
|
||||
sessionId: "session-a",
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
})
|
||||
|
||||
// when
|
||||
const endedState = endTaskTimer(TEST_DIR, workId, "todo:1", "2026-01-01T00:00:01.500Z")
|
||||
|
||||
// then
|
||||
const taskSession = endedState?.works?.[workId]?.task_sessions?.["todo:1"]
|
||||
expect(taskSession?.ended_at).toBe("2026-01-01T00:00:01.500Z")
|
||||
expect(taskSession?.elapsed_ms).toBe(1500)
|
||||
expect(taskSession?.status).toBe("completed")
|
||||
})
|
||||
|
||||
test("should complete one work and keep other work untouched", () => {
|
||||
// given
|
||||
const initialState = createBoulderState(join(TEST_DIR, ".sisyphus/plans/plan-a.md"), "session-a")
|
||||
writeBoulderState(TEST_DIR, initialState)
|
||||
const firstWorkId = initialState.active_work_id!
|
||||
const withSecond = addBoulderWork(TEST_DIR, {
|
||||
planPath: join(TEST_DIR, ".sisyphus/plans/plan-b.md"),
|
||||
sessionId: "session-b",
|
||||
})
|
||||
const secondWorkId = Object.keys(withSecond!.works!).find((workId) => workId !== firstWorkId)!
|
||||
|
||||
// when
|
||||
const completedState = completeBoulder(TEST_DIR, firstWorkId, "2026-01-01T01:00:00.000Z")
|
||||
|
||||
// then
|
||||
expect(completedState?.works?.[firstWorkId]?.status).toBe("completed")
|
||||
expect(completedState?.works?.[firstWorkId]?.ended_at).toBe("2026-01-01T01:00:00.000Z")
|
||||
expect(completedState?.works?.[firstWorkId]?.elapsed_ms).toBe(
|
||||
Date.parse("2026-01-01T01:00:00.000Z") - Date.parse(completedState!.works![firstWorkId]!.started_at),
|
||||
)
|
||||
expect(completedState?.works?.[secondWorkId]?.status).not.toBe("completed")
|
||||
expect(existsSync(join(SISYPHUS_DIR, "boulder.json"))).toBe(true)
|
||||
})
|
||||
|
||||
test("should keep first completion timing when completeBoulder is called repeatedly", () => {
|
||||
// given
|
||||
const initialState = createBoulderState(
|
||||
join(TEST_DIR, ".sisyphus/plans/plan-idempotent.md"),
|
||||
"session-a",
|
||||
)
|
||||
writeBoulderState(TEST_DIR, initialState)
|
||||
const workId = initialState.active_work_id!
|
||||
|
||||
// when
|
||||
const firstCompletedState = completeBoulder(TEST_DIR, workId, "2026-01-01T00:01:00Z")
|
||||
const secondCompletedState = completeBoulder(TEST_DIR, workId, "2026-01-01T01:00:00Z")
|
||||
|
||||
// then
|
||||
expect(firstCompletedState?.works?.[workId]?.ended_at).toBe("2026-01-01T00:01:00Z")
|
||||
expect(secondCompletedState?.works?.[workId]?.ended_at).toBe("2026-01-01T00:01:00Z")
|
||||
expect(secondCompletedState?.works?.[workId]?.elapsed_ms).toBe(
|
||||
Date.parse("2026-01-01T00:01:00Z") - Date.parse(secondCompletedState!.works![workId]!.started_at),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("readCurrentTopLevelTask", () => {
|
||||
test("should return the first unchecked top-level task in TODOs", () => {
|
||||
// given - plan with nested and top-level unchecked tasks
|
||||
@@ -630,7 +888,8 @@ describe("boulder-state", () => {
|
||||
const progress = getPlanProgress("/non/existent/file.md")
|
||||
// then
|
||||
expect(progress.total).toBe(0)
|
||||
expect(progress.isComplete).toBe(true)
|
||||
expect(progress.completed).toBe(0)
|
||||
expect(progress.isComplete).toBe(false)
|
||||
})
|
||||
|
||||
test("should support asterisk bullet top-level tasks", () => {
|
||||
|
||||
@@ -6,11 +6,103 @@
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs"
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"
|
||||
import type { BoulderState, PlanProgress, TaskSessionState } from "./types"
|
||||
import type {
|
||||
BoulderSessionOrigin,
|
||||
BoulderState,
|
||||
BoulderWorkResumeOption,
|
||||
BoulderWorkState,
|
||||
BoulderWorkStatus,
|
||||
PlanProgress,
|
||||
TaskSessionState,
|
||||
} from "./types"
|
||||
import { BOULDER_DIR, BOULDER_FILE, PROMETHEUS_PLANS_DIR } from "./constants"
|
||||
|
||||
const RESERVED_KEYS = new Set(["__proto__", "prototype", "constructor"])
|
||||
|
||||
function nowIsoString(): string {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
function parseIsoToMs(value: string | undefined): number | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = Date.parse(value)
|
||||
return Number.isNaN(parsed) ? null : parsed
|
||||
}
|
||||
|
||||
function getElapsedMs(startedAt: string | undefined, endedAt: string | undefined): number | undefined {
|
||||
const startedMs = parseIsoToMs(startedAt)
|
||||
const endedMs = parseIsoToMs(endedAt)
|
||||
if (startedMs === null || endedMs === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return endedMs - startedMs
|
||||
}
|
||||
|
||||
function isValidWorkStatus(status: unknown): status is BoulderWorkStatus {
|
||||
return status === "active" || status === "completed" || status === "paused" || status === "abandoned"
|
||||
}
|
||||
|
||||
function buildWorkFromMirror(state: BoulderState): BoulderWorkState {
|
||||
const planName = state.plan_name ?? getPlanName(state.active_plan)
|
||||
const workId = `${planName}-legacy`
|
||||
return {
|
||||
work_id: workId,
|
||||
active_plan: state.active_plan,
|
||||
plan_name: planName,
|
||||
status: state.status,
|
||||
started_at: state.started_at,
|
||||
ended_at: state.ended_at,
|
||||
elapsed_ms: state.elapsed_ms,
|
||||
updated_at: state.updated_at,
|
||||
session_ids: Array.isArray(state.session_ids) ? [...state.session_ids] : [],
|
||||
session_origins: state.session_origins,
|
||||
agent: state.agent,
|
||||
worktree_path: state.worktree_path,
|
||||
task_sessions: state.task_sessions,
|
||||
}
|
||||
}
|
||||
|
||||
function projectWorkToMirror(state: BoulderState, work: BoulderWorkState): void {
|
||||
state.active_plan = work.active_plan
|
||||
state.plan_name = work.plan_name
|
||||
state.status = work.status
|
||||
state.started_at = work.started_at
|
||||
state.ended_at = work.ended_at
|
||||
state.elapsed_ms = work.elapsed_ms
|
||||
state.updated_at = work.updated_at
|
||||
state.session_ids = [...work.session_ids]
|
||||
state.session_origins = work.session_origins ? { ...work.session_origins } : {}
|
||||
state.agent = work.agent
|
||||
state.worktree_path = work.worktree_path
|
||||
state.task_sessions = work.task_sessions ? { ...work.task_sessions } : {}
|
||||
}
|
||||
|
||||
function selectMirrorWork(state: BoulderState): BoulderWorkState | null {
|
||||
const works = getBoulderWorks(state)
|
||||
if (works.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (state.active_work_id) {
|
||||
const matched = works.find((work) => work.work_id === state.active_work_id)
|
||||
if (matched) {
|
||||
return matched
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...works].sort((left, right) => {
|
||||
const leftMs = parseIsoToMs(left.updated_at ?? left.started_at) ?? 0
|
||||
const rightMs = parseIsoToMs(right.updated_at ?? right.started_at) ?? 0
|
||||
return rightMs - leftMs
|
||||
})
|
||||
|
||||
return sorted[0] ?? null
|
||||
}
|
||||
|
||||
export function getBoulderFilePath(directory: string): string {
|
||||
return join(directory, BOULDER_DIR, BOULDER_FILE)
|
||||
}
|
||||
@@ -80,7 +172,15 @@ export function readBoulderState(directory: string): BoulderState | null {
|
||||
if (!parsed.task_sessions || typeof parsed.task_sessions !== "object" || Array.isArray(parsed.task_sessions)) {
|
||||
parsed.task_sessions = {}
|
||||
}
|
||||
return parsed as BoulderState
|
||||
|
||||
const state = parsed as BoulderState
|
||||
const mirrorWork = selectMirrorWork(state)
|
||||
if (mirrorWork) {
|
||||
state.active_work_id = mirrorWork.work_id
|
||||
projectWorkToMirror(state, mirrorWork)
|
||||
}
|
||||
|
||||
return state
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -95,7 +195,33 @@ export function writeBoulderState(directory: string, state: BoulderState): boole
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
writeFileSync(filePath, JSON.stringify(state, null, 2), "utf-8")
|
||||
const stateToWrite: BoulderState = { ...state }
|
||||
if (stateToWrite.works && stateToWrite.active_work_id) {
|
||||
const activeWork = stateToWrite.works[stateToWrite.active_work_id]
|
||||
if (activeWork) {
|
||||
const nextActiveWork: BoulderWorkState = {
|
||||
...activeWork,
|
||||
active_plan: stateToWrite.active_plan,
|
||||
plan_name: stateToWrite.plan_name,
|
||||
status: stateToWrite.status,
|
||||
started_at: stateToWrite.started_at,
|
||||
ended_at: stateToWrite.ended_at,
|
||||
elapsed_ms: stateToWrite.elapsed_ms,
|
||||
updated_at: stateToWrite.updated_at,
|
||||
session_ids: [...stateToWrite.session_ids],
|
||||
session_origins: stateToWrite.session_origins ? { ...stateToWrite.session_origins } : {},
|
||||
agent: stateToWrite.agent,
|
||||
worktree_path: stateToWrite.worktree_path,
|
||||
task_sessions: stateToWrite.task_sessions ? { ...stateToWrite.task_sessions } : {},
|
||||
}
|
||||
stateToWrite.works = {
|
||||
...stateToWrite.works,
|
||||
[stateToWrite.active_work_id]: nextActiveWork,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(filePath, JSON.stringify(stateToWrite, null, 2), "utf-8")
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
@@ -107,6 +233,11 @@ export function appendSessionId(
|
||||
sessionId: string,
|
||||
origin: "direct" | "appended" = "direct",
|
||||
): BoulderState | null {
|
||||
const activeWorkId = readBoulderState(directory)?.active_work_id
|
||||
if (activeWorkId) {
|
||||
return appendSessionIdForWork(directory, activeWorkId, sessionId, origin)
|
||||
}
|
||||
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) return null
|
||||
|
||||
@@ -156,6 +287,14 @@ export function clearBoulderState(directory: string): boolean {
|
||||
|
||||
export function getTaskSessionState(directory: string, taskKey: string): TaskSessionState | null {
|
||||
const state = readBoulderState(directory)
|
||||
if (state?.active_work_id) {
|
||||
const work = state.works?.[state.active_work_id]
|
||||
const taskSession = work?.task_sessions?.[taskKey]
|
||||
if (taskSession) {
|
||||
return taskSession
|
||||
}
|
||||
}
|
||||
|
||||
if (!state?.task_sessions) {
|
||||
return null
|
||||
}
|
||||
@@ -174,6 +313,11 @@ export function upsertTaskSessionState(
|
||||
category?: string
|
||||
},
|
||||
): BoulderState | null {
|
||||
const stateForWork = readBoulderState(directory)
|
||||
if (stateForWork?.active_work_id) {
|
||||
return upsertTaskSessionStateForWork(directory, stateForWork.active_work_id, input)
|
||||
}
|
||||
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return null
|
||||
@@ -251,7 +395,7 @@ type ProgressSection = "todo" | "final-wave" | "other"
|
||||
*/
|
||||
export function getPlanProgress(planPath: string): PlanProgress {
|
||||
if (!existsSync(planPath)) {
|
||||
return { total: 0, completed: 0, isComplete: true }
|
||||
return { total: 0, completed: 0, isComplete: false }
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -272,7 +416,7 @@ export function getPlanProgress(planPath: string): PlanProgress {
|
||||
// Simple plan: count all top-level checkboxes anywhere
|
||||
return getSimplePlanProgress(content)
|
||||
} catch {
|
||||
return { total: 0, completed: 0, isComplete: true }
|
||||
return { total: 0, completed: 0, isComplete: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,15 +499,479 @@ export function createBoulderState(
|
||||
agent?: string,
|
||||
worktreePath?: string,
|
||||
): BoulderState {
|
||||
return {
|
||||
const startedAt = nowIsoString()
|
||||
const workId = generateWorkId(getPlanName(planPath))
|
||||
const work: BoulderWorkState = {
|
||||
work_id: workId,
|
||||
active_plan: planPath,
|
||||
started_at: new Date().toISOString(),
|
||||
plan_name: getPlanName(planPath),
|
||||
status: "active",
|
||||
started_at: startedAt,
|
||||
updated_at: startedAt,
|
||||
session_ids: [sessionId],
|
||||
session_origins: {
|
||||
[sessionId]: "direct",
|
||||
},
|
||||
...(agent !== undefined ? { agent } : {}),
|
||||
...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}),
|
||||
task_sessions: {},
|
||||
}
|
||||
|
||||
return {
|
||||
schema_version: 2,
|
||||
active_work_id: workId,
|
||||
works: {
|
||||
[workId]: work,
|
||||
},
|
||||
active_plan: planPath,
|
||||
started_at: startedAt,
|
||||
status: "active",
|
||||
updated_at: startedAt,
|
||||
session_ids: [sessionId],
|
||||
session_origins: {
|
||||
[sessionId]: "direct",
|
||||
},
|
||||
plan_name: getPlanName(planPath),
|
||||
task_sessions: {},
|
||||
...(agent !== undefined ? { agent } : {}),
|
||||
...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function generateWorkId(planName: string): string {
|
||||
const slug = planName
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
const randomHex = Math.floor(Math.random() * 0xffffffff)
|
||||
.toString(16)
|
||||
.padStart(8, "0")
|
||||
const safeSlug = slug.length > 0 ? slug : "work"
|
||||
return `${safeSlug}-${randomHex}`
|
||||
}
|
||||
|
||||
export function getBoulderWorks(state: BoulderState): BoulderWorkState[] {
|
||||
if (state.works && typeof state.works === "object") {
|
||||
return Object.values(state.works)
|
||||
}
|
||||
|
||||
if (!state.active_plan || !state.plan_name || !state.started_at) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [buildWorkFromMirror(state)]
|
||||
}
|
||||
|
||||
export function getActiveWorks(directory: string): BoulderWorkState[] {
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return []
|
||||
}
|
||||
|
||||
return getBoulderWorks(state).filter((work) => work.status !== "completed" && work.status !== "abandoned")
|
||||
}
|
||||
|
||||
export function getWorkById(directory: string, workId: string): BoulderWorkState | null {
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
|
||||
return getBoulderWorks(state).find((work) => work.work_id === workId) ?? null
|
||||
}
|
||||
|
||||
export function getWorkByPlanName(
|
||||
directory: string,
|
||||
planName: string,
|
||||
options?: { worktreePath?: string },
|
||||
): BoulderWorkState | null {
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
|
||||
const worktreePath = options?.worktreePath
|
||||
return getBoulderWorks(state).find((work) => {
|
||||
if (work.plan_name !== planName) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!worktreePath) {
|
||||
return true
|
||||
}
|
||||
|
||||
return work.worktree_path === worktreePath
|
||||
}) ?? null
|
||||
}
|
||||
|
||||
export function getWorkForSession(directory: string, sessionId: string): BoulderWorkState | null {
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
|
||||
const works = getBoulderWorks(state)
|
||||
.filter((work) => work.session_ids.includes(sessionId))
|
||||
.sort((left, right) => {
|
||||
const leftMs = parseIsoToMs(left.updated_at ?? left.started_at) ?? 0
|
||||
const rightMs = parseIsoToMs(right.updated_at ?? right.started_at) ?? 0
|
||||
return rightMs - leftMs
|
||||
})
|
||||
|
||||
if (works.length > 0) {
|
||||
return works[0] ?? null
|
||||
}
|
||||
|
||||
if (state.session_ids.includes(sessionId)) {
|
||||
return buildWorkFromMirror(state)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveBoulderPlanPathForWork(
|
||||
directory: string,
|
||||
work: Pick<BoulderWorkState, "active_plan" | "worktree_path">,
|
||||
): string {
|
||||
return resolveBoulderPlanPath(directory, work)
|
||||
}
|
||||
|
||||
export function getWorkResumeOptions(directory: string): BoulderWorkResumeOption[] {
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return []
|
||||
}
|
||||
|
||||
return getActiveWorks(directory).map((work) => {
|
||||
const progress = getPlanProgress(resolveBoulderPlanPathForWork(directory, work))
|
||||
return {
|
||||
work_id: work.work_id,
|
||||
plan_name: work.plan_name,
|
||||
active_plan: work.active_plan,
|
||||
worktree_path: work.worktree_path,
|
||||
status: work.status && isValidWorkStatus(work.status) ? work.status : "active",
|
||||
started_at: work.started_at,
|
||||
updated_at: work.updated_at ?? work.started_at,
|
||||
ended_at: work.ended_at,
|
||||
elapsed_ms: work.elapsed_ms,
|
||||
session_count: work.session_ids.length,
|
||||
progress,
|
||||
is_current_mirror: state.active_work_id === work.work_id,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function selectActiveWork(directory: string, workId: string): BoulderState | null {
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
|
||||
const works = getBoulderWorks(state)
|
||||
const nextWork = works.find((work) => work.work_id === workId)
|
||||
if (!nextWork) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nextState: BoulderState = {
|
||||
...state,
|
||||
schema_version: 2,
|
||||
active_work_id: workId,
|
||||
works: state.works ?? Object.fromEntries(works.map((work) => [work.work_id, work])),
|
||||
}
|
||||
projectWorkToMirror(nextState, nextWork)
|
||||
|
||||
if (!writeBoulderState(directory, nextState)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return nextState
|
||||
}
|
||||
|
||||
export function addBoulderWork(
|
||||
directory: string,
|
||||
input: {
|
||||
planPath: string
|
||||
sessionId: string
|
||||
agent?: string
|
||||
worktreePath?: string
|
||||
startedAt?: string
|
||||
},
|
||||
): BoulderState | null {
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
|
||||
const workId = generateWorkId(getPlanName(input.planPath))
|
||||
const startedAt = input.startedAt ?? nowIsoString()
|
||||
const nextWork: BoulderWorkState = {
|
||||
work_id: workId,
|
||||
active_plan: input.planPath,
|
||||
plan_name: getPlanName(input.planPath),
|
||||
status: "active",
|
||||
started_at: startedAt,
|
||||
updated_at: startedAt,
|
||||
session_ids: [input.sessionId],
|
||||
session_origins: {
|
||||
[input.sessionId]: "direct",
|
||||
},
|
||||
...(input.agent !== undefined ? { agent: input.agent } : {}),
|
||||
...(input.worktreePath !== undefined ? { worktree_path: input.worktreePath } : {}),
|
||||
task_sessions: {},
|
||||
}
|
||||
|
||||
const works = getBoulderWorks(state)
|
||||
const nextWorks: Record<string, BoulderWorkState> = {
|
||||
...Object.fromEntries(works.map((work) => [work.work_id, work])),
|
||||
[workId]: nextWork,
|
||||
}
|
||||
|
||||
const nextState: BoulderState = {
|
||||
...state,
|
||||
schema_version: 2,
|
||||
works: nextWorks,
|
||||
active_work_id: workId,
|
||||
}
|
||||
projectWorkToMirror(nextState, nextWork)
|
||||
|
||||
if (!writeBoulderState(directory, nextState)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return nextState
|
||||
}
|
||||
|
||||
export function appendSessionIdForWork(
|
||||
directory: string,
|
||||
workId: string,
|
||||
sessionId: string,
|
||||
origin: BoulderSessionOrigin = "direct",
|
||||
): BoulderState | null {
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
|
||||
const works = getBoulderWorks(state)
|
||||
const targetWork = works.find((work) => work.work_id === workId)
|
||||
if (!targetWork) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sessionIds = targetWork.session_ids.includes(sessionId)
|
||||
? [...targetWork.session_ids]
|
||||
: [...targetWork.session_ids, sessionId]
|
||||
const sessionOrigins = {
|
||||
...(targetWork.session_origins ?? {}),
|
||||
[sessionId]: origin,
|
||||
}
|
||||
|
||||
const updatedWork: BoulderWorkState = {
|
||||
...targetWork,
|
||||
session_ids: sessionIds,
|
||||
session_origins: sessionOrigins,
|
||||
updated_at: nowIsoString(),
|
||||
}
|
||||
const nextWorks = {
|
||||
...Object.fromEntries(works.map((work) => [work.work_id, work])),
|
||||
[workId]: updatedWork,
|
||||
}
|
||||
|
||||
const nextState: BoulderState = {
|
||||
...state,
|
||||
schema_version: 2,
|
||||
works: nextWorks,
|
||||
}
|
||||
if (state.active_work_id === workId) {
|
||||
projectWorkToMirror(nextState, updatedWork)
|
||||
}
|
||||
|
||||
if (!writeBoulderState(directory, nextState)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return nextState
|
||||
}
|
||||
|
||||
export function upsertTaskSessionStateForWork(
|
||||
directory: string,
|
||||
workId: string,
|
||||
input: {
|
||||
taskKey: string
|
||||
taskLabel: string
|
||||
taskTitle: string
|
||||
sessionId: string
|
||||
agent?: string
|
||||
category?: string
|
||||
},
|
||||
): BoulderState | null {
|
||||
if (RESERVED_KEYS.has(input.taskKey)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
|
||||
const works = getBoulderWorks(state)
|
||||
const targetWork = works.find((work) => work.work_id === workId)
|
||||
if (!targetWork) {
|
||||
return null
|
||||
}
|
||||
|
||||
const previousTaskSession = targetWork.task_sessions?.[input.taskKey]
|
||||
const nextTaskSession: TaskSessionState = {
|
||||
task_key: input.taskKey,
|
||||
task_label: input.taskLabel,
|
||||
task_title: input.taskTitle,
|
||||
session_id: input.sessionId,
|
||||
...(input.agent !== undefined ? { agent: input.agent } : {}),
|
||||
...(input.category !== undefined ? { category: input.category } : {}),
|
||||
...(previousTaskSession?.started_at !== undefined ? { started_at: previousTaskSession.started_at } : {}),
|
||||
...(previousTaskSession?.ended_at !== undefined ? { ended_at: previousTaskSession.ended_at } : {}),
|
||||
...(previousTaskSession?.elapsed_ms !== undefined ? { elapsed_ms: previousTaskSession.elapsed_ms } : {}),
|
||||
...(previousTaskSession?.status !== undefined ? { status: previousTaskSession.status } : {}),
|
||||
updated_at: nowIsoString(),
|
||||
}
|
||||
|
||||
const nextWork: BoulderWorkState = {
|
||||
...targetWork,
|
||||
task_sessions: {
|
||||
...(targetWork.task_sessions ?? {}),
|
||||
[input.taskKey]: nextTaskSession,
|
||||
},
|
||||
updated_at: nowIsoString(),
|
||||
}
|
||||
|
||||
const nextWorks = {
|
||||
...Object.fromEntries(works.map((work) => [work.work_id, work])),
|
||||
[workId]: nextWork,
|
||||
}
|
||||
|
||||
const nextState: BoulderState = {
|
||||
...state,
|
||||
schema_version: 2,
|
||||
works: nextWorks,
|
||||
}
|
||||
if (state.active_work_id === workId) {
|
||||
projectWorkToMirror(nextState, nextWork)
|
||||
}
|
||||
|
||||
if (!writeBoulderState(directory, nextState)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return nextState
|
||||
}
|
||||
|
||||
export function startTaskTimer(
|
||||
directory: string,
|
||||
workId: string,
|
||||
input: {
|
||||
taskKey: string
|
||||
taskLabel: string
|
||||
taskTitle: string
|
||||
sessionId: string
|
||||
agent?: string
|
||||
category?: string
|
||||
startedAt?: string
|
||||
},
|
||||
): BoulderState | null {
|
||||
const nextState = upsertTaskSessionStateForWork(directory, workId, input)
|
||||
if (!nextState) {
|
||||
return null
|
||||
}
|
||||
|
||||
const work = nextState.works?.[workId]
|
||||
const taskSession = work?.task_sessions?.[input.taskKey]
|
||||
if (!work || !taskSession) {
|
||||
return null
|
||||
}
|
||||
|
||||
const startedAt = taskSession.started_at ?? input.startedAt ?? nowIsoString()
|
||||
taskSession.started_at = startedAt
|
||||
taskSession.status = "running"
|
||||
taskSession.updated_at = nowIsoString()
|
||||
work.updated_at = nowIsoString()
|
||||
|
||||
if (!writeBoulderState(directory, nextState)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return nextState
|
||||
}
|
||||
|
||||
export function endTaskTimer(
|
||||
directory: string,
|
||||
workId: string,
|
||||
taskKey: string,
|
||||
endedAt?: string,
|
||||
): BoulderState | null {
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
|
||||
const work = state.works?.[workId] ?? getBoulderWorks(state).find((candidate) => candidate.work_id === workId)
|
||||
if (!work?.task_sessions?.[taskKey]) {
|
||||
return null
|
||||
}
|
||||
|
||||
const taskSession = work.task_sessions[taskKey]
|
||||
const endAt = endedAt ?? nowIsoString()
|
||||
taskSession.ended_at = endAt
|
||||
taskSession.elapsed_ms = getElapsedMs(taskSession.started_at, endAt)
|
||||
taskSession.status = "completed"
|
||||
taskSession.updated_at = nowIsoString()
|
||||
work.updated_at = nowIsoString()
|
||||
|
||||
if (state.active_work_id === workId) {
|
||||
projectWorkToMirror(state, work)
|
||||
}
|
||||
|
||||
if (!writeBoulderState(directory, state)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
export function completeBoulder(directory: string, workId?: string, endedAt?: string): BoulderState | null {
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
|
||||
const targetWorkId = workId ?? state.active_work_id
|
||||
if (!targetWorkId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const work = state.works?.[targetWorkId] ?? getBoulderWorks(state).find((candidate) => candidate.work_id === targetWorkId)
|
||||
if (!work) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (work.status === "completed" && work.ended_at !== undefined && work.elapsed_ms !== undefined) {
|
||||
return state
|
||||
}
|
||||
|
||||
const endAt = endedAt ?? nowIsoString()
|
||||
work.ended_at = endAt
|
||||
work.elapsed_ms = getElapsedMs(work.started_at, endAt)
|
||||
work.status = "completed"
|
||||
work.updated_at = nowIsoString()
|
||||
|
||||
if (state.active_work_id === targetWorkId) {
|
||||
projectWorkToMirror(state, work)
|
||||
}
|
||||
|
||||
if (!writeBoulderState(directory, state)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type {
|
||||
BoulderSessionOrigin,
|
||||
BoulderState,
|
||||
BoulderTaskStatus,
|
||||
BoulderWorkResumeOption,
|
||||
BoulderWorkState,
|
||||
BoulderWorkStatus,
|
||||
PlanProgress,
|
||||
TaskSessionState,
|
||||
} from "./types"
|
||||
|
||||
describe("boulder-state types", () => {
|
||||
test("keeps legacy BoulderState assignable while allowing v2 fields", () => {
|
||||
// given
|
||||
const legacyState: BoulderState = {
|
||||
active_plan: "/tmp/plan.md",
|
||||
started_at: "2026-01-01T00:00:00.000Z",
|
||||
session_ids: ["ses_1"],
|
||||
plan_name: "plan",
|
||||
}
|
||||
|
||||
// when
|
||||
const hasLegacyShape = legacyState.active_plan.length > 0
|
||||
|
||||
// then
|
||||
expect(hasLegacyShape).toBe(true)
|
||||
})
|
||||
|
||||
test("supports multi-work and timer fields", () => {
|
||||
// given
|
||||
const taskStatus: BoulderTaskStatus = "running"
|
||||
const workStatus: BoulderWorkStatus = "active"
|
||||
const origin: BoulderSessionOrigin = "direct"
|
||||
|
||||
const taskSession: TaskSessionState = {
|
||||
task_key: "todo:1",
|
||||
task_label: "1",
|
||||
task_title: "Do work",
|
||||
session_id: "ses_task",
|
||||
started_at: "2026-01-01T00:00:00.000Z",
|
||||
ended_at: "2026-01-01T00:00:01.000Z",
|
||||
elapsed_ms: 1000,
|
||||
status: taskStatus,
|
||||
updated_at: "2026-01-01T00:00:01.000Z",
|
||||
}
|
||||
|
||||
const work: BoulderWorkState = {
|
||||
work_id: "plan-abc12345",
|
||||
active_plan: "/tmp/plan.md",
|
||||
plan_name: "plan",
|
||||
status: workStatus,
|
||||
started_at: "2026-01-01T00:00:00.000Z",
|
||||
session_ids: ["ses_1"],
|
||||
session_origins: { ses_1: origin },
|
||||
task_sessions: { "todo:1": taskSession },
|
||||
}
|
||||
|
||||
const progress: PlanProgress = { total: 2, completed: 1, isComplete: false }
|
||||
const resumeOption: BoulderWorkResumeOption = {
|
||||
work_id: work.work_id,
|
||||
plan_name: work.plan_name,
|
||||
active_plan: work.active_plan,
|
||||
status: "paused",
|
||||
started_at: work.started_at,
|
||||
updated_at: "2026-01-01T00:00:02.000Z",
|
||||
session_count: 1,
|
||||
progress,
|
||||
is_current_mirror: false,
|
||||
}
|
||||
|
||||
// when
|
||||
const combined = { taskSession, work, resumeOption }
|
||||
|
||||
// then
|
||||
expect(combined.resumeOption.progress.total).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -6,10 +6,17 @@
|
||||
*/
|
||||
|
||||
export interface BoulderState {
|
||||
schema_version?: 2
|
||||
active_work_id?: string
|
||||
works?: Record<string, BoulderWorkState>
|
||||
/** Absolute path to the active plan file */
|
||||
active_plan: string
|
||||
/** ISO timestamp when work started */
|
||||
started_at: string
|
||||
ended_at?: string
|
||||
elapsed_ms?: number
|
||||
status?: BoulderWorkStatus
|
||||
updated_at?: string
|
||||
/** Session IDs that have worked on this plan */
|
||||
session_ids: string[]
|
||||
session_origins?: Record<string, "direct" | "appended">
|
||||
@@ -23,6 +30,26 @@ export interface BoulderState {
|
||||
task_sessions?: Record<string, TaskSessionState>
|
||||
}
|
||||
|
||||
export type BoulderSessionOrigin = "direct" | "appended"
|
||||
export type BoulderWorkStatus = "active" | "completed" | "paused" | "abandoned"
|
||||
export type BoulderTaskStatus = "running" | "completed" | "cancelled"
|
||||
|
||||
export interface BoulderWorkState {
|
||||
work_id: string
|
||||
active_plan: string
|
||||
plan_name: string
|
||||
status?: BoulderWorkStatus
|
||||
started_at: string
|
||||
ended_at?: string
|
||||
elapsed_ms?: number
|
||||
updated_at?: string
|
||||
session_ids: string[]
|
||||
session_origins?: Record<string, BoulderSessionOrigin>
|
||||
agent?: string
|
||||
worktree_path?: string
|
||||
task_sessions?: Record<string, TaskSessionState>
|
||||
}
|
||||
|
||||
export interface PlanProgress {
|
||||
/** Total number of checkboxes */
|
||||
total: number
|
||||
@@ -45,10 +72,29 @@ export interface TaskSessionState {
|
||||
agent?: string
|
||||
/** Category associated with the task session, when known */
|
||||
category?: string
|
||||
started_at?: string
|
||||
ended_at?: string
|
||||
elapsed_ms?: number
|
||||
status?: BoulderTaskStatus
|
||||
/** Last update timestamp */
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface BoulderWorkResumeOption {
|
||||
work_id: string
|
||||
plan_name: string
|
||||
active_plan: string
|
||||
worktree_path?: string
|
||||
status: BoulderWorkStatus
|
||||
started_at: string
|
||||
updated_at: string
|
||||
ended_at?: string
|
||||
elapsed_ms?: number
|
||||
session_count: number
|
||||
progress: PlanProgress
|
||||
is_current_mirror: boolean
|
||||
}
|
||||
|
||||
export interface TopLevelTaskRef {
|
||||
/** Stable identifier for the current top-level plan task */
|
||||
key: string
|
||||
|
||||
@@ -16,9 +16,13 @@ export const START_WORK_TEMPLATE = `You are starting a Sisyphus work session.
|
||||
2. **Check for active boulder state**: Read \`.sisyphus/boulder.json\` if it exists
|
||||
|
||||
3. **Decision logic**:
|
||||
- If \`.sisyphus/boulder.json\` exists AND plan is NOT complete (has unchecked boxes):
|
||||
- **APPEND** current session to session_ids
|
||||
- Continue work on existing plan
|
||||
- If multiple active works are listed in your context:
|
||||
- This means boulder.json has more than one work with status: \`active\` or \`paused\`
|
||||
- Use the Question tool to ask the user which plan to resume
|
||||
- Resume by running \`/start-work {plan-name}\` for the selected plan
|
||||
- If the user says "start a new plan", continue with cold-start auto-selection logic
|
||||
- If exactly one active work is listed and the user did not name a plan:
|
||||
- Auto-resume that single active work
|
||||
- If no active plan OR plan is complete:
|
||||
- List available plan files
|
||||
- If ONE plan: auto-select it
|
||||
|
||||
@@ -8,6 +8,7 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) {
|
||||
const sessions = new Map<string, SessionState>()
|
||||
const pendingFilePaths = new Map<string, string>()
|
||||
const pendingTaskRefs = new Map<string, PendingTaskRef>()
|
||||
const pendingPlanSnapshots = new Map<string, string>()
|
||||
const autoCommit = options?.autoCommit ?? true
|
||||
|
||||
function getState(sessionID: string): SessionState {
|
||||
@@ -25,12 +26,14 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) {
|
||||
ctx,
|
||||
pendingFilePaths,
|
||||
pendingTaskRefs,
|
||||
pendingPlanSnapshots,
|
||||
isCallerOrchestrator: options?.isCallerOrchestrator,
|
||||
}),
|
||||
"tool.execute.after": createToolExecuteAfterHandler({
|
||||
ctx,
|
||||
pendingFilePaths,
|
||||
pendingTaskRefs,
|
||||
pendingPlanSnapshots,
|
||||
autoCommit,
|
||||
getState,
|
||||
isCallerOrchestrator: options?.isCallerOrchestrator,
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { appendSessionId, type BoulderState, resolveBoulderPlanPath, upsertTaskSessionState } from "../../features/boulder-state"
|
||||
import {
|
||||
appendSessionId,
|
||||
appendSessionIdForWork,
|
||||
getWorkForSession,
|
||||
type BoulderState,
|
||||
resolveBoulderPlanPath,
|
||||
resolveBoulderPlanPathForWork,
|
||||
upsertTaskSessionState,
|
||||
upsertTaskSessionStateForWork,
|
||||
} from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { extractSessionIdFromOutput, validateSubagentSessionId } from "./subagent-session-id"
|
||||
@@ -19,8 +28,13 @@ export async function syncBackgroundLaunchSessionTracking(input: {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof toolInput.sessionID !== "string") {
|
||||
return
|
||||
}
|
||||
|
||||
const trackedWork = getWorkForSession(ctx.directory, toolInput.sessionID)
|
||||
const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output)
|
||||
const lineageSessionIDs = boulderState.session_ids
|
||||
const lineageSessionIDs = trackedWork?.session_ids ?? boulderState.session_ids
|
||||
const subagentSessionId = await validateSubagentSessionId({
|
||||
client: ctx.client,
|
||||
sessionID: extractedSessionId,
|
||||
@@ -36,22 +50,39 @@ export async function syncBackgroundLaunchSessionTracking(input: {
|
||||
return
|
||||
}
|
||||
|
||||
appendSessionId(ctx.directory, trackedSessionId, "appended")
|
||||
if (trackedWork) {
|
||||
appendSessionIdForWork(ctx.directory, trackedWork.work_id, trackedSessionId, "appended")
|
||||
} else {
|
||||
appendSessionId(ctx.directory, trackedSessionId, "appended")
|
||||
}
|
||||
|
||||
const { currentTask, shouldSkipTaskSessionUpdate } = resolveTaskContext(
|
||||
pendingTaskRef,
|
||||
resolveBoulderPlanPath(ctx.directory, boulderState),
|
||||
trackedWork
|
||||
? resolveBoulderPlanPathForWork(ctx.directory, trackedWork)
|
||||
: resolveBoulderPlanPath(ctx.directory, boulderState),
|
||||
)
|
||||
|
||||
if (currentTask && !shouldSkipTaskSessionUpdate) {
|
||||
upsertTaskSessionState(ctx.directory, {
|
||||
taskKey: currentTask.key,
|
||||
taskLabel: currentTask.label,
|
||||
taskTitle: currentTask.title,
|
||||
sessionId: trackedSessionId,
|
||||
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
|
||||
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
|
||||
})
|
||||
if (trackedWork) {
|
||||
upsertTaskSessionStateForWork(ctx.directory, trackedWork.work_id, {
|
||||
taskKey: currentTask.key,
|
||||
taskLabel: currentTask.label,
|
||||
taskTitle: currentTask.title,
|
||||
sessionId: trackedSessionId,
|
||||
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
|
||||
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
|
||||
})
|
||||
} else {
|
||||
upsertTaskSessionState(ctx.directory, {
|
||||
taskKey: currentTask.key,
|
||||
taskLabel: currentTask.label,
|
||||
taskTitle: currentTask.title,
|
||||
sessionId: trackedSessionId,
|
||||
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
|
||||
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Background launch session tracked`, {
|
||||
@@ -81,17 +112,3 @@ async function resolveFallbackTrackedSessionId(input: {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSessionOrigin(
|
||||
ctx: PluginInput,
|
||||
sessionID: string,
|
||||
): Promise<"direct" | "appended"> {
|
||||
try {
|
||||
const session = await ctx.client.session.get({ path: { id: sessionID } })
|
||||
return typeof session.data?.parentID === "string" && session.data.parentID.length > 0
|
||||
? "appended"
|
||||
: "direct"
|
||||
} catch {
|
||||
return "appended"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||
|
||||
const { createAtlasHook } = await import("./index")
|
||||
|
||||
describe("atlas hook idle-event complete boulder", () => {
|
||||
let testDirectory = ""
|
||||
|
||||
beforeEach(() => {
|
||||
testDirectory = join(tmpdir(), `atlas-idle-complete-${randomUUID()}`)
|
||||
if (!existsSync(testDirectory)) {
|
||||
mkdirSync(testDirectory, { recursive: true })
|
||||
}
|
||||
clearBoulderState(testDirectory)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearBoulderState(testDirectory)
|
||||
if (existsSync(testDirectory)) {
|
||||
rmSync(testDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("marks work completed with ended_at and elapsed_ms when progress is complete", async () => {
|
||||
// given
|
||||
const sessionID = "ses_complete"
|
||||
const planPath = join(testDirectory, "complete-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [x] 1. Done\n", "utf-8")
|
||||
writeBoulderState(testDirectory, {
|
||||
schema_version: 2,
|
||||
active_work_id: "work-complete",
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00.000Z",
|
||||
session_ids: [sessionID],
|
||||
plan_name: "complete-plan",
|
||||
works: {
|
||||
"work-complete": {
|
||||
work_id: "work-complete",
|
||||
active_plan: planPath,
|
||||
plan_name: "complete-plan",
|
||||
started_at: "2026-01-02T10:00:00.000Z",
|
||||
session_ids: [sessionID],
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const hook = createAtlasHook({
|
||||
directory: testDirectory,
|
||||
client: {
|
||||
session: {
|
||||
get: async () => ({ data: { id: sessionID } }),
|
||||
messages: async () => ({ data: [] }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
promptAsync: async () => ({ data: {} }),
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createAtlasHook>[0])
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
const work = readBoulderState(testDirectory)?.works?.["work-complete"]
|
||||
expect(work?.status).toBe("completed")
|
||||
expect(work?.ended_at).toBeString()
|
||||
expect((work?.elapsed_ms ?? 0) > 0).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { createBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||
import { _resetForTesting, registerAgentName } from "../../features/claude-code-session-state"
|
||||
import { handleAtlasSessionIdle } from "./idle-event"
|
||||
import type { SessionState } from "./types"
|
||||
|
||||
describe("handleAtlasSessionIdle completion nudge", () => {
|
||||
const SESSION_ID = "session-main-1"
|
||||
|
||||
let testDirectory = ""
|
||||
|
||||
beforeEach(() => {
|
||||
testDirectory = join(tmpdir(), `atlas-idle-complete-${randomUUID()}`)
|
||||
if (!existsSync(testDirectory)) {
|
||||
mkdirSync(testDirectory, { recursive: true })
|
||||
}
|
||||
_resetForTesting()
|
||||
registerAgentName("atlas")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(testDirectory)) {
|
||||
rmSync(testDirectory, { recursive: true, force: true })
|
||||
}
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
it("injects BOULDER COMPLETE prompt once per work with substituted elapsed and task breakdown", async () => {
|
||||
// given
|
||||
const planPath = join(testDirectory, "plan.md")
|
||||
writeFileSync(planPath, "## TODOs\n- [x] 1. Parse input\n- [x] 2. Save output\n")
|
||||
|
||||
const boulder = createBoulderState(planPath, SESSION_ID, "atlas")
|
||||
const workId = boulder.active_work_id
|
||||
if (!workId) {
|
||||
throw new Error("Expected active_work_id")
|
||||
}
|
||||
|
||||
const work = boulder.works?.[workId]
|
||||
if (!work) {
|
||||
throw new Error("Expected active work")
|
||||
}
|
||||
|
||||
work.elapsed_ms = 65_000
|
||||
boulder.elapsed_ms = 65_000
|
||||
work.task_sessions = {
|
||||
"todo:2": {
|
||||
task_key: "todo:2",
|
||||
task_label: "2",
|
||||
task_title: "Save output",
|
||||
session_id: "sub-2",
|
||||
elapsed_ms: 4_000,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
"todo:1": {
|
||||
task_key: "todo:1",
|
||||
task_label: "1",
|
||||
task_title: "Parse input",
|
||||
session_id: "sub-1",
|
||||
elapsed_ms: 61_000,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
boulder.task_sessions = work.task_sessions
|
||||
|
||||
writeBoulderState(testDirectory, boulder)
|
||||
|
||||
const promptRequests: Array<{ body?: { parts?: Array<{ text?: string }> } }> = []
|
||||
const promptAsyncMock = mock(async (request: { body?: { parts?: Array<{ text?: string }> } }) => {
|
||||
promptRequests.push(request)
|
||||
return { data: {} }
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
directory: testDirectory,
|
||||
client: {
|
||||
session: {
|
||||
promptAsync: promptAsyncMock,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
|
||||
const sessionStateById = new Map<string, SessionState>()
|
||||
const getState = (sessionId: string): SessionState => {
|
||||
let state = sessionStateById.get(sessionId)
|
||||
if (!state) {
|
||||
state = { promptFailureCount: 0 }
|
||||
sessionStateById.set(sessionId, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// when
|
||||
await handleAtlasSessionIdle({
|
||||
ctx,
|
||||
sessionID: SESSION_ID,
|
||||
getState,
|
||||
})
|
||||
|
||||
await handleAtlasSessionIdle({
|
||||
ctx,
|
||||
sessionID: SESSION_ID,
|
||||
getState,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
const promptText = promptRequests[0]?.body?.parts?.[0]?.text ?? ""
|
||||
expect(promptText).toContain("BOULDER COMPLETE")
|
||||
expect(promptText).toContain("Total elapsed: 1m 5s")
|
||||
expect(promptText).toContain("- 1 Parse input: 1m 1s")
|
||||
expect(promptText).toContain("- 2 Save output: 4s")
|
||||
expect(promptText).not.toContain("{ELAPSED_HUMAN}")
|
||||
|
||||
const persistedState = getState(SESSION_ID)
|
||||
expect(persistedState.boulderCompletionNudgedAt?.[workId]).toBeNumber()
|
||||
expect(readBoulderState(testDirectory)?.works?.[workId]?.status).toBe("completed")
|
||||
})
|
||||
})
|
||||
@@ -1,20 +1,29 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import {
|
||||
completeBoulder,
|
||||
formatDurationHuman,
|
||||
getPlanProgress,
|
||||
getWorkForSession,
|
||||
getTaskSessionState,
|
||||
readBoulderState,
|
||||
readCurrentTopLevelTask,
|
||||
resolveBoulderPlanPath,
|
||||
} from "../../features/boulder-state"
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import {
|
||||
getSessionAgent,
|
||||
isAgentRegistered,
|
||||
resolveRegisteredAgentName,
|
||||
} from "../../features/claude-code-session-state"
|
||||
import { getLastAgentFromSession } from "./session-last-agent"
|
||||
import { isSessionInBoulderLineage } from "./boulder-session-lineage"
|
||||
import { createInternalAgentTextPart } from "../../shared"
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
import { log } from "../../shared/logger"
|
||||
import { settleAfterSessionIdle } from "../shared/session-idle-settle"
|
||||
import { injectBoulderContinuation } from "./boulder-continuation-injector"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
|
||||
import { BOULDER_COMPLETE_PROMPT } from "./system-reminder-templates"
|
||||
import type { AtlasHookOptions, SessionState } from "./types"
|
||||
|
||||
const CONTINUATION_COOLDOWN_MS = 5000
|
||||
@@ -22,6 +31,11 @@ const FAILURE_BACKOFF_MS = 5 * 60 * 1000
|
||||
const MAX_CONSECUTIVE_PROMPT_FAILURES = 10
|
||||
const RETRY_DELAY_MS = CONTINUATION_COOLDOWN_MS + 1000
|
||||
|
||||
function getTaskLabelSortValue(taskLabel: string): number {
|
||||
const parsed = Number.parseInt(taskLabel.replace(/[^0-9]/g, ""), 10)
|
||||
return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed
|
||||
}
|
||||
|
||||
function hasRunningBackgroundTasks(sessionID: string, options?: AtlasHookOptions): boolean {
|
||||
const backgroundManager = options?.backgroundManager
|
||||
return backgroundManager
|
||||
@@ -205,6 +219,7 @@ export async function handleAtlasSessionIdle(input: {
|
||||
sessionID: string
|
||||
}): Promise<void> {
|
||||
const { ctx, options, getState, sessionID } = input
|
||||
const sessionState = getState(sessionID)
|
||||
|
||||
log(`[${HOOK_NAME}] session.idle`, { sessionID })
|
||||
|
||||
@@ -220,6 +235,68 @@ export async function handleAtlasSessionIdle(input: {
|
||||
|
||||
const { boulderState, progress, appendedSession } = activeBoulderSession
|
||||
if (progress.isComplete) {
|
||||
const work = getWorkForSession(ctx.directory, sessionID)
|
||||
if (work) {
|
||||
completeBoulder(ctx.directory, work.work_id)
|
||||
} else {
|
||||
completeBoulder(ctx.directory, boulderState.active_work_id)
|
||||
}
|
||||
|
||||
if (!work || work.status === "abandoned") {
|
||||
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionState.boulderCompletionNudgedAt?.[work.work_id]) {
|
||||
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
|
||||
return
|
||||
}
|
||||
|
||||
const elapsedMilliseconds = work.elapsed_ms ?? (Date.now() - new Date(work.started_at).getTime())
|
||||
const elapsedHuman = formatDurationHuman(elapsedMilliseconds)
|
||||
|
||||
const taskBreakdown = Object.values(work.task_sessions ?? {})
|
||||
.sort((left, right) => {
|
||||
const leftSortValue = getTaskLabelSortValue(left.task_label)
|
||||
const rightSortValue = getTaskLabelSortValue(right.task_label)
|
||||
if (leftSortValue !== rightSortValue) {
|
||||
return leftSortValue - rightSortValue
|
||||
}
|
||||
|
||||
return left.task_label.localeCompare(right.task_label)
|
||||
})
|
||||
.map((task) => {
|
||||
if (typeof task.elapsed_ms === "number") {
|
||||
return `- ${task.task_label} ${task.task_title}: ${formatDurationHuman(task.elapsed_ms)}`
|
||||
}
|
||||
|
||||
return `- ${task.task_label} ${task.task_title}: (no timing)`
|
||||
})
|
||||
.join("\n")
|
||||
|
||||
const prompt = BOULDER_COMPLETE_PROMPT
|
||||
.replace(/{PLAN_NAME}/g, work.plan_name)
|
||||
.replace(/{ELAPSED_HUMAN}/g, elapsedHuman)
|
||||
.replace(/{TASK_BREAKDOWN}/g, taskBreakdown.length > 0 ? taskBreakdown : "- (no task timings)")
|
||||
|
||||
const atlasAgent = resolveRegisteredAgentName(
|
||||
boulderState.agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined),
|
||||
)
|
||||
if (atlasAgent && isAgentRegistered(atlasAgent)) {
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: atlasAgent,
|
||||
parts: [createInternalAgentTextPart(prompt)],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
sessionState.boulderCompletionNudgedAt = {
|
||||
...(sessionState.boulderCompletionNudgedAt ?? {}),
|
||||
[work.work_id]: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
|
||||
return
|
||||
}
|
||||
@@ -246,7 +323,6 @@ export async function handleAtlasSessionIdle(input: {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionState = getState(sessionID)
|
||||
const now = Date.now()
|
||||
|
||||
if (sessionState.waitingForFinalWaveApproval) {
|
||||
|
||||
@@ -1490,7 +1490,7 @@ session_id: ses_untrusted_999
|
||||
expect(callArgs.body.parts[0].text).toContain("2 remaining")
|
||||
})
|
||||
|
||||
test("should not inject when boulder plan is complete", async () => {
|
||||
test("should inject completion nudge when boulder plan is complete", async () => {
|
||||
// given - boulder state with complete plan
|
||||
const planPath = join(TEST_DIR, "complete-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [x] Task 1\n- [x] Task 2")
|
||||
@@ -1514,11 +1514,11 @@ session_id: ses_untrusted_999
|
||||
},
|
||||
})
|
||||
|
||||
// then - should not call prompt
|
||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||
// then
|
||||
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test("should not inject when the mirrored worktree plan is complete even if the main repo plan is stale", async () => {
|
||||
test("should inject completion nudge when mirrored worktree plan is complete even if the main repo plan is stale", async () => {
|
||||
// given
|
||||
const mainPlanPath = join(TEST_DIR, ".sisyphus", "plans", "worktree-complete-plan.md")
|
||||
const worktreeDir = join(tmpdir(), `atlas-worktree-${randomUUID()}`)
|
||||
@@ -1549,7 +1549,7 @@ session_id: ses_untrusted_999
|
||||
})
|
||||
|
||||
// then
|
||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
rmSync(worktreeDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@@ -131,4 +131,77 @@ describe("resolveActiveBoulderSession", () => {
|
||||
rmSync(worktreeDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("uses work resolved by session id when works map is present", async () => {
|
||||
// given
|
||||
const legacyPlanPath = join(testDirectory, "legacy-plan.md")
|
||||
const workAPlanPath = join(testDirectory, "work-a-plan.md")
|
||||
const workBPlanPath = join(testDirectory, "work-b-plan.md")
|
||||
writeFileSync(legacyPlanPath, "# Plan\n- [ ] Legacy\n", "utf-8")
|
||||
writeFileSync(workAPlanPath, "# Plan\n- [ ] Work A\n", "utf-8")
|
||||
writeFileSync(workBPlanPath, "# Plan\n- [x] Work B\n", "utf-8")
|
||||
|
||||
writeBoulderState(testDirectory, {
|
||||
schema_version: 2,
|
||||
active_work_id: "work-a",
|
||||
active_plan: legacyPlanPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["ses_legacy"],
|
||||
plan_name: "legacy-plan",
|
||||
works: {
|
||||
"work-a": {
|
||||
work_id: "work-a",
|
||||
active_plan: workAPlanPath,
|
||||
plan_name: "work-a-plan",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["ses_work_a"],
|
||||
status: "active",
|
||||
},
|
||||
"work-b": {
|
||||
work_id: "work-b",
|
||||
active_plan: workBPlanPath,
|
||||
plan_name: "work-b-plan",
|
||||
started_at: "2026-01-02T11:00:00Z",
|
||||
session_ids: ["ses_work_b"],
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// when
|
||||
const result = await resolveActiveBoulderSession({
|
||||
client: { session: { get: async () => ({ data: {} }) } } as never,
|
||||
directory: testDirectory,
|
||||
sessionID: "ses_work_b",
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.boulderState.active_plan).toBe(workBPlanPath)
|
||||
expect(result?.progress.isComplete).toBe(true)
|
||||
})
|
||||
|
||||
test("falls back to top-level mirror when works map is missing", async () => {
|
||||
// given
|
||||
const legacyPlanPath = join(testDirectory, "legacy-only-plan.md")
|
||||
writeFileSync(legacyPlanPath, "# Plan\n- [ ] Task 1\n", "utf-8")
|
||||
writeBoulderState(testDirectory, {
|
||||
active_plan: legacyPlanPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["ses_legacy_only"],
|
||||
plan_name: "legacy-only-plan",
|
||||
})
|
||||
|
||||
// when
|
||||
const result = await resolveActiveBoulderSession({
|
||||
client: { session: { get: async () => ({ data: {} }) } } as never,
|
||||
directory: testDirectory,
|
||||
sessionID: "ses_legacy_only",
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.boulderState.active_plan).toBe(legacyPlanPath)
|
||||
expect(result?.progress.isComplete).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { getPlanProgress, readBoulderState, resolveBoulderPlanPath } from "../../features/boulder-state"
|
||||
import {
|
||||
getPlanProgress,
|
||||
getWorkForSession,
|
||||
readBoulderState,
|
||||
resolveBoulderPlanPath,
|
||||
resolveBoulderPlanPathForWork,
|
||||
} from "../../features/boulder-state"
|
||||
import type { BoulderState, PlanProgress } from "../../features/boulder-state"
|
||||
|
||||
export async function resolveActiveBoulderSession(input: {
|
||||
@@ -16,14 +22,37 @@ export async function resolveActiveBoulderSession(input: {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!boulderState.session_ids.includes(input.sessionID)) {
|
||||
const sessionWork = getWorkForSession(input.directory, input.sessionID)
|
||||
if (!sessionWork && !boulderState.session_ids.includes(input.sessionID)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const progress = getPlanProgress(resolveBoulderPlanPath(input.directory, boulderState))
|
||||
const nextBoulderState: BoulderState = sessionWork
|
||||
? {
|
||||
...boulderState,
|
||||
active_plan: sessionWork.active_plan,
|
||||
plan_name: sessionWork.plan_name,
|
||||
status: sessionWork.status,
|
||||
started_at: sessionWork.started_at,
|
||||
ended_at: sessionWork.ended_at,
|
||||
elapsed_ms: sessionWork.elapsed_ms,
|
||||
updated_at: sessionWork.updated_at,
|
||||
session_ids: [...sessionWork.session_ids],
|
||||
session_origins: sessionWork.session_origins ? { ...sessionWork.session_origins } : {},
|
||||
agent: sessionWork.agent,
|
||||
worktree_path: sessionWork.worktree_path,
|
||||
task_sessions: sessionWork.task_sessions ? { ...sessionWork.task_sessions } : {},
|
||||
}
|
||||
: boulderState
|
||||
|
||||
const progress = getPlanProgress(
|
||||
sessionWork
|
||||
? resolveBoulderPlanPathForWork(input.directory, sessionWork)
|
||||
: resolveBoulderPlanPath(input.directory, nextBoulderState),
|
||||
)
|
||||
if (progress.isComplete) {
|
||||
return { boulderState, progress, appendedSession: false }
|
||||
return { boulderState: nextBoulderState, progress, appendedSession: false }
|
||||
}
|
||||
|
||||
return { boulderState, progress, appendedSession: false }
|
||||
return { boulderState: nextBoulderState, progress, appendedSession: false }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import {
|
||||
BOULDER_COMPLETE_PROMPT,
|
||||
BOULDER_CONTINUATION_PROMPT,
|
||||
SINGLE_TASK_DIRECTIVE,
|
||||
VERIFICATION_REMINDER,
|
||||
@@ -47,6 +48,14 @@ describe("VERIFICATION_REMINDER", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("BOULDER_COMPLETE_PROMPT", () => {
|
||||
it("contains the required placeholders", () => {
|
||||
expect(BOULDER_COMPLETE_PROMPT).toContain("{PLAN_NAME}")
|
||||
expect(BOULDER_COMPLETE_PROMPT).toContain("{ELAPSED_HUMAN}")
|
||||
expect(BOULDER_COMPLETE_PROMPT).toContain("{TASK_BREAKDOWN}")
|
||||
})
|
||||
})
|
||||
|
||||
describe("VERIFICATION_REMINDER_GEMINI", () => {
|
||||
it("contains node_modules exclusion pathspec in git diff command", () => {
|
||||
expect(VERIFICATION_REMINDER_GEMINI).toContain(":!node_modules")
|
||||
|
||||
@@ -33,6 +33,17 @@ RULES:
|
||||
- Do not stop until all tasks are complete
|
||||
- If blocked, document the blocker and move to the next task`
|
||||
|
||||
export const BOULDER_COMPLETE_PROMPT = `<system-reminder>
|
||||
BOULDER COMPLETE: plan "{PLAN_NAME}" is fully checked.
|
||||
|
||||
Total elapsed: {ELAPSED_HUMAN}
|
||||
|
||||
Per-task breakdown:
|
||||
{TASK_BREAKDOWN}
|
||||
|
||||
Per your <boulder_completion_response> instructions, print the final ORCHESTRATION COMPLETE summary in your next turn. This nudge fires at most once.
|
||||
</system-reminder>`
|
||||
|
||||
export const VERIFICATION_REMINDER = `**THE SUBAGENT JUST CLAIMED THIS TASK IS DONE. THEY ARE PROBABLY LYING.**
|
||||
|
||||
Subagents say "done" when code has errors, tests pass trivially, logic is wrong,
|
||||
|
||||
@@ -424,6 +424,94 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
|
||||
expect(readBoulderState(testDirectory)?.session_ids).not.toContain(sessionID)
|
||||
expect(readBoulderState(testDirectory)?.session_ids).not.toContain(childSessionID)
|
||||
})
|
||||
|
||||
it("#then it should append launched child to the session-resolved work", async () => {
|
||||
const parentSessionID = "ses_parent_for_work"
|
||||
const childSessionID = "ses_child_for_work"
|
||||
const planPathA = join(testDirectory, "background-launch-work-a.md")
|
||||
const planPathB = join(testDirectory, "background-launch-work-b.md")
|
||||
const project = createProject()
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => createSessionGetResult(undefined),
|
||||
},
|
||||
} as unknown as PluginInput["client"]
|
||||
|
||||
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
|
||||
createSessionGetResult(input?.path?.id === childSessionID ? parentSessionID : undefined),
|
||||
) as never)
|
||||
|
||||
writeFileSync(planPathA, "# Plan\n\n## TODOs\n- [ ] 1. Work A\n")
|
||||
writeFileSync(planPathB, "# Plan\n\n## TODOs\n- [ ] 1. Work B\n")
|
||||
|
||||
writeBoulderState(testDirectory, {
|
||||
schema_version: 2,
|
||||
active_work_id: "work-a",
|
||||
active_plan: planPathA,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["ses_unrelated_active"],
|
||||
plan_name: "background-launch-work-a",
|
||||
works: {
|
||||
"work-a": {
|
||||
work_id: "work-a",
|
||||
active_plan: planPathA,
|
||||
plan_name: "background-launch-work-a",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["ses_unrelated_active"],
|
||||
status: "active",
|
||||
},
|
||||
"work-b": {
|
||||
work_id: "work-b",
|
||||
active_plan: planPathB,
|
||||
plan_name: "background-launch-work-b",
|
||||
started_at: "2026-01-02T10:05:00Z",
|
||||
session_ids: [parentSessionID],
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const pendingFilePaths = new Map<string, string>()
|
||||
const pendingTaskRefs = new Map()
|
||||
const ctx = {
|
||||
client,
|
||||
project,
|
||||
directory: testDirectory,
|
||||
worktree: testDirectory,
|
||||
serverUrl: new URL("https://example.com"),
|
||||
$: Bun.$,
|
||||
} satisfies PluginInput
|
||||
const beforeHandler = createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs })
|
||||
const afterHandler = createToolExecuteAfterHandler({
|
||||
ctx,
|
||||
pendingFilePaths,
|
||||
pendingTaskRefs,
|
||||
autoCommit: true,
|
||||
getState: () => ({ promptFailureCount: 0 }),
|
||||
})
|
||||
|
||||
await beforeHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-bg-work" },
|
||||
{ args: { prompt: "Work B" } },
|
||||
)
|
||||
|
||||
await afterHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-bg-work" },
|
||||
{
|
||||
title: "Sisyphus Task",
|
||||
output: "Background task launched.\n\nBackground Task ID: bg_work\n\n<task_metadata>\nsession_id: ses_child_for_work\n</task_metadata>",
|
||||
metadata: {
|
||||
sessionId: childSessionID,
|
||||
agent: "sisyphus-junior",
|
||||
category: "deep",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const boulderState = readBoulderState(testDirectory)
|
||||
expect(boulderState?.works?.["work-b"]?.session_ids).toContain(childSessionID)
|
||||
expect(boulderState?.works?.["work-a"]?.session_ids).not.toContain(childSessionID)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { Project } from "@opencode-ai/sdk"
|
||||
import { readBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||
import { createToolExecuteBeforeHandler } from "./tool-execute-before"
|
||||
|
||||
const isCallerOrchestratorMock = mock(async () => true)
|
||||
const collectGitDiffStatsMock = mock(() => ({
|
||||
filesChanged: 0,
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
}))
|
||||
|
||||
mock.module("../../shared/session-utils", () => ({
|
||||
isCallerOrchestrator: isCallerOrchestratorMock,
|
||||
}))
|
||||
|
||||
mock.module("../../shared/git-worktree", () => ({
|
||||
collectGitDiffStats: collectGitDiffStatsMock,
|
||||
formatFileChanges: mock(() => "No file changes"),
|
||||
}))
|
||||
|
||||
afterAll(() => { mock.restore() })
|
||||
|
||||
const { createToolExecuteAfterHandler } = await import("./tool-execute-after")
|
||||
|
||||
type SessionGetInput = { path: { id: string } }
|
||||
type SessionGetResult = {
|
||||
data: { parentID: string | undefined }
|
||||
error?: undefined
|
||||
request: Request
|
||||
response: Response
|
||||
}
|
||||
|
||||
describe("createToolExecuteAfterHandler task timers", () => {
|
||||
let testDirectory = ""
|
||||
|
||||
beforeEach(() => {
|
||||
testDirectory = join(tmpdir(), `atlas-task-timers-${crypto.randomUUID()}`)
|
||||
if (!existsSync(testDirectory)) {
|
||||
mkdirSync(testDirectory, { recursive: true })
|
||||
}
|
||||
isCallerOrchestratorMock.mockClear()
|
||||
collectGitDiffStatsMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (testDirectory && existsSync(testDirectory)) {
|
||||
rmSync(testDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function createProject(): Project {
|
||||
return {
|
||||
id: "project-1",
|
||||
worktree: testDirectory,
|
||||
time: { created: Date.now() },
|
||||
}
|
||||
}
|
||||
|
||||
function createSessionGetResult(parentID: string | undefined): SessionGetResult {
|
||||
return {
|
||||
data: { parentID },
|
||||
error: undefined,
|
||||
request: new Request("https://example.com/session"),
|
||||
response: new Response(null, { status: 200 }),
|
||||
} as SessionGetResult
|
||||
}
|
||||
|
||||
function createHandlers(parentSessionIDs?: Record<string, string | undefined>) {
|
||||
const project = createProject()
|
||||
const client = {
|
||||
session: {
|
||||
get: async (input: SessionGetInput) => createSessionGetResult(parentSessionIDs?.[input.path.id]),
|
||||
},
|
||||
} as PluginInput["client"]
|
||||
|
||||
if (parentSessionIDs) {
|
||||
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
|
||||
createSessionGetResult(parentSessionIDs[input?.path?.id ?? ""]),
|
||||
) as never)
|
||||
}
|
||||
|
||||
const pendingFilePaths = new Map<string, string>()
|
||||
const pendingTaskRefs = new Map()
|
||||
const pendingPlanSnapshots = new Map<string, string>()
|
||||
const ctx = {
|
||||
client,
|
||||
project,
|
||||
directory: testDirectory,
|
||||
worktree: testDirectory,
|
||||
serverUrl: new URL("https://example.com"),
|
||||
$: Bun.$,
|
||||
} satisfies PluginInput
|
||||
|
||||
return {
|
||||
beforeHandler: createToolExecuteBeforeHandler({
|
||||
ctx,
|
||||
pendingFilePaths,
|
||||
pendingTaskRefs,
|
||||
pendingPlanSnapshots,
|
||||
}),
|
||||
afterHandler: createToolExecuteAfterHandler({
|
||||
ctx,
|
||||
pendingFilePaths,
|
||||
pendingTaskRefs,
|
||||
pendingPlanSnapshots,
|
||||
autoCommit: true,
|
||||
getState: () => ({ promptFailureCount: 0 }),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
it("starts task timer for todo:1 when delegated task session is tracked", async () => {
|
||||
// given
|
||||
const parentSessionID = "ses_parent"
|
||||
const childSessionID = "ses_child"
|
||||
const planPath = join(testDirectory, "task-timer-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. Implement auth flow\n", "utf-8")
|
||||
writeBoulderState(testDirectory, {
|
||||
schema_version: 2,
|
||||
active_work_id: "work-1",
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [parentSessionID],
|
||||
plan_name: "task-timer-plan",
|
||||
works: {
|
||||
"work-1": {
|
||||
work_id: "work-1",
|
||||
active_plan: planPath,
|
||||
plan_name: "task-timer-plan",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [parentSessionID],
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
})
|
||||
const { beforeHandler, afterHandler } = createHandlers({
|
||||
[childSessionID]: parentSessionID,
|
||||
})
|
||||
|
||||
await beforeHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-task-timer-1" },
|
||||
{ args: { prompt: "Implement auth flow" } },
|
||||
)
|
||||
|
||||
// when
|
||||
await afterHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-task-timer-1" },
|
||||
{
|
||||
title: "Sisyphus Task",
|
||||
output: "Task completed\n<task_metadata>\nsession_id: ses_child\n</task_metadata>",
|
||||
metadata: {
|
||||
sessionId: childSessionID,
|
||||
agent: "sisyphus-junior",
|
||||
category: "deep",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// then
|
||||
const taskSession = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions?.["todo:1"]
|
||||
expect(taskSession).toBeDefined()
|
||||
expect(taskSession?.started_at).toBeString()
|
||||
expect(taskSession?.status).toBe("running")
|
||||
expect(taskSession?.session_id).toBe(childSessionID)
|
||||
})
|
||||
|
||||
it("ends task timer when todo:1 checkbox transitions to checked", async () => {
|
||||
// given
|
||||
const parentSessionID = "ses_parent_2"
|
||||
const childSessionID = "ses_child_2"
|
||||
const planPath = join(testDirectory, "task-timer-complete-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. Implement auth flow\n", "utf-8")
|
||||
writeBoulderState(testDirectory, {
|
||||
schema_version: 2,
|
||||
active_work_id: "work-1",
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [parentSessionID],
|
||||
plan_name: "task-timer-complete-plan",
|
||||
works: {
|
||||
"work-1": {
|
||||
work_id: "work-1",
|
||||
active_plan: planPath,
|
||||
plan_name: "task-timer-complete-plan",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [parentSessionID],
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
})
|
||||
const { beforeHandler, afterHandler } = createHandlers({
|
||||
[childSessionID]: parentSessionID,
|
||||
})
|
||||
|
||||
await beforeHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-task-timer-2" },
|
||||
{ args: { prompt: "Implement auth flow" } },
|
||||
)
|
||||
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [x] 1. Implement auth flow\n", "utf-8")
|
||||
|
||||
// when
|
||||
await afterHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-task-timer-2" },
|
||||
{
|
||||
title: "Sisyphus Task",
|
||||
output: "Task completed\n<task_metadata>\nsession_id: ses_child_2\n</task_metadata>",
|
||||
metadata: {
|
||||
sessionId: childSessionID,
|
||||
agent: "sisyphus-junior",
|
||||
category: "deep",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// then
|
||||
const taskSession = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions?.["todo:1"]
|
||||
expect(taskSession).toBeDefined()
|
||||
expect(taskSession?.ended_at).toBeString()
|
||||
expect(taskSession?.status).toBe("completed")
|
||||
expect(typeof taskSession?.elapsed_ms).toBe("number")
|
||||
})
|
||||
|
||||
it("ends task timer when plan checkbox flips to checked via edit tool", async () => {
|
||||
// given
|
||||
const parentSessionID = "ses_parent_3"
|
||||
const planDirectory = join(testDirectory, ".sisyphus", "plans")
|
||||
mkdirSync(planDirectory, { recursive: true })
|
||||
const planPath = join(planDirectory, "task-timer-edit-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. Implement auth flow\n", "utf-8")
|
||||
writeBoulderState(testDirectory, {
|
||||
schema_version: 2,
|
||||
active_work_id: "work-1",
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [parentSessionID],
|
||||
plan_name: "task-timer-edit-plan",
|
||||
task_sessions: {
|
||||
"todo:1": {
|
||||
task_key: "todo:1",
|
||||
task_label: "1",
|
||||
task_title: "Implement auth flow",
|
||||
session_id: "ses_child_3",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
status: "running",
|
||||
updated_at: "2026-01-02T10:00:00Z",
|
||||
},
|
||||
},
|
||||
works: {
|
||||
"work-1": {
|
||||
work_id: "work-1",
|
||||
active_plan: planPath,
|
||||
plan_name: "task-timer-edit-plan",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [parentSessionID],
|
||||
status: "active",
|
||||
task_sessions: {},
|
||||
},
|
||||
},
|
||||
})
|
||||
const { beforeHandler, afterHandler } = createHandlers()
|
||||
|
||||
await beforeHandler(
|
||||
{ tool: "edit", sessionID: parentSessionID, callID: "call-task-timer-edit-1" },
|
||||
{ args: { filePath: planPath, oldString: "- [ ] 1. Implement auth flow", newString: "- [x] 1. Implement auth flow" } },
|
||||
)
|
||||
|
||||
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [x] 1. Implement auth flow\n", "utf-8")
|
||||
|
||||
// when
|
||||
await afterHandler(
|
||||
{ tool: "edit", sessionID: parentSessionID, callID: "call-task-timer-edit-1" },
|
||||
{
|
||||
title: "Edit",
|
||||
output: "Updated file",
|
||||
metadata: {
|
||||
filePath: planPath,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// then
|
||||
const taskSession = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions?.["todo:1"]
|
||||
expect(taskSession).toBeDefined()
|
||||
expect(taskSession?.ended_at).toBeString()
|
||||
expect(taskSession?.status).toBe("completed")
|
||||
expect(typeof taskSession?.elapsed_ms).toBe("number")
|
||||
expect((taskSession?.elapsed_ms ?? 0) > 0).toBe(true)
|
||||
})
|
||||
|
||||
it("tracks parallel delegated tasks by task label from TASK section", async () => {
|
||||
// given
|
||||
const parentSessionID = "ses_parent_parallel"
|
||||
const planPath = join(testDirectory, "task-timer-parallel-plan.md")
|
||||
writeFileSync(
|
||||
planPath,
|
||||
"# Plan\n\n## TODOs\n- [ ] 1. First task\n- [ ] 2. Add tests\n- [ ] 3. Write docs\n",
|
||||
"utf-8",
|
||||
)
|
||||
writeBoulderState(testDirectory, {
|
||||
schema_version: 2,
|
||||
active_work_id: "work-1",
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [parentSessionID],
|
||||
plan_name: "task-timer-parallel-plan",
|
||||
works: {
|
||||
"work-1": {
|
||||
work_id: "work-1",
|
||||
active_plan: planPath,
|
||||
plan_name: "task-timer-parallel-plan",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [parentSessionID],
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
})
|
||||
const { beforeHandler, afterHandler } = createHandlers({
|
||||
ses_child_parallel_2: parentSessionID,
|
||||
ses_child_parallel_3: parentSessionID,
|
||||
})
|
||||
|
||||
await beforeHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-task-parallel-2" },
|
||||
{
|
||||
args: {
|
||||
prompt: "## 1. TASK\n- [ ] 2. Add tests\n\n## 2. CONTEXT\n...",
|
||||
},
|
||||
},
|
||||
)
|
||||
await beforeHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-task-parallel-3" },
|
||||
{
|
||||
args: {
|
||||
prompt: "## 1. TASK\n- [ ] 3. Write docs\n\n## 2. CONTEXT\n...",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// when
|
||||
await afterHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-task-parallel-2" },
|
||||
{
|
||||
title: "Sisyphus Task",
|
||||
output: "Task completed\n<task_metadata>\nsession_id: ses_child_parallel_2\n</task_metadata>",
|
||||
metadata: {
|
||||
sessionId: "ses_child_parallel_2",
|
||||
agent: "sisyphus-junior",
|
||||
category: "deep",
|
||||
},
|
||||
},
|
||||
)
|
||||
await afterHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-task-parallel-3" },
|
||||
{
|
||||
title: "Sisyphus Task",
|
||||
output: "Task completed\n<task_metadata>\nsession_id: ses_child_parallel_3\n</task_metadata>",
|
||||
metadata: {
|
||||
sessionId: "ses_child_parallel_3",
|
||||
agent: "sisyphus-junior",
|
||||
category: "deep",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// then
|
||||
const taskSessions = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions
|
||||
expect(taskSessions?.["todo:2"]?.task_key).toBe("todo:2")
|
||||
expect(taskSessions?.["todo:3"]?.task_key).toBe("todo:3")
|
||||
expect(taskSessions?.["todo:1"]).toBeUndefined()
|
||||
})
|
||||
|
||||
it("falls back to current top-level task when TASK section label is missing", async () => {
|
||||
// given
|
||||
const parentSessionID = "ses_parent_fallback"
|
||||
const childSessionID = "ses_child_fallback"
|
||||
const planPath = join(testDirectory, "task-timer-fallback-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. First task\n", "utf-8")
|
||||
writeBoulderState(testDirectory, {
|
||||
schema_version: 2,
|
||||
active_work_id: "work-1",
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [parentSessionID],
|
||||
plan_name: "task-timer-fallback-plan",
|
||||
works: {
|
||||
"work-1": {
|
||||
work_id: "work-1",
|
||||
active_plan: planPath,
|
||||
plan_name: "task-timer-fallback-plan",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [parentSessionID],
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
})
|
||||
const { beforeHandler, afterHandler } = createHandlers({
|
||||
[childSessionID]: parentSessionID,
|
||||
})
|
||||
|
||||
await beforeHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-task-fallback-1" },
|
||||
{
|
||||
args: {
|
||||
prompt: "No structured header in this prompt",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// when
|
||||
await afterHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-task-fallback-1" },
|
||||
{
|
||||
title: "Sisyphus Task",
|
||||
output: "Task completed\n<task_metadata>\nsession_id: ses_child_fallback\n</task_metadata>",
|
||||
metadata: {
|
||||
sessionId: childSessionID,
|
||||
agent: "sisyphus-junior",
|
||||
category: "deep",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// then
|
||||
const taskSessions = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions
|
||||
expect(taskSessions?.["todo:1"]?.task_key).toBe("todo:1")
|
||||
})
|
||||
|
||||
})
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import {
|
||||
endTaskTimer,
|
||||
getWorkForSession,
|
||||
getPlanProgress,
|
||||
getTaskSessionState,
|
||||
readBoulderState,
|
||||
resolveBoulderPlanPath,
|
||||
resolveBoulderPlanPathForWork,
|
||||
startTaskTimer,
|
||||
upsertTaskSessionState,
|
||||
} from "../../features/boulder-state"
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { resolve } from "node:path"
|
||||
import { log } from "../../shared/logger"
|
||||
import { isCallerOrchestrator } from "../../shared/session-utils"
|
||||
import { syncBackgroundLaunchSessionTracking } from "./background-launch-session-tracking"
|
||||
@@ -26,15 +32,105 @@ import { isWriteOrEditToolName } from "./write-edit-tool-policy"
|
||||
import type { PendingTaskRef, SessionState } from "./types"
|
||||
import type { ToolExecuteAfterInput, ToolExecuteAfterOutput } from "./types"
|
||||
|
||||
function isTrackedTaskChecked(planPath: string, taskKey: string): boolean {
|
||||
if (!existsSync(planPath)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const [section, label] = taskKey.split(":")
|
||||
if (!section || !label) {
|
||||
return false
|
||||
}
|
||||
|
||||
const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
const matcher = section === "todo"
|
||||
? new RegExp(`^\\s*[-*]\\s*\\[[xX]\\]\\s*${escapedLabel}\\.\\s+`, "m")
|
||||
: section === "final-wave"
|
||||
? new RegExp(`^\\s*[-*]\\s*\\[[xX]\\]\\s*${escapedLabel.toUpperCase()}\\.\\s+`, "m")
|
||||
: null
|
||||
if (!matcher) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(planPath, "utf-8")
|
||||
return matcher.test(content)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const TODO_HEADING_PATTERN = /^##\s+TODOs\b/i
|
||||
const FINAL_VERIFICATION_HEADING_PATTERN = /^##\s+Final Verification Wave\b/i
|
||||
const SECOND_LEVEL_HEADING_PATTERN = /^##\s+/
|
||||
const CHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[[xX]\]\s*(.+)$/
|
||||
const TODO_TASK_PATTERN = /^(\d+)\.\s+(.+)$/
|
||||
const FINAL_WAVE_TASK_PATTERN = /^(F\d+)\.\s+(.+)$/i
|
||||
|
||||
function parseCheckedTopLevelTaskKeys(planContent: string): Set<string> {
|
||||
const checkedKeys = new Set<string>()
|
||||
const lines = planContent.split(/\r?\n/)
|
||||
let section: "todo" | "final-wave" | "other" = "other"
|
||||
|
||||
for (const line of lines) {
|
||||
if (SECOND_LEVEL_HEADING_PATTERN.test(line)) {
|
||||
section = TODO_HEADING_PATTERN.test(line)
|
||||
? "todo"
|
||||
: FINAL_VERIFICATION_HEADING_PATTERN.test(line)
|
||||
? "final-wave"
|
||||
: "other"
|
||||
continue
|
||||
}
|
||||
|
||||
if (section !== "todo" && section !== "final-wave") {
|
||||
continue
|
||||
}
|
||||
|
||||
const checkedMatch = line.match(CHECKED_CHECKBOX_PATTERN)
|
||||
if (!checkedMatch || checkedMatch[1].length > 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const taskBody = checkedMatch[2].trim()
|
||||
if (section === "todo") {
|
||||
const taskMatch = taskBody.match(TODO_TASK_PATTERN)
|
||||
if (taskMatch?.[1]) {
|
||||
checkedKeys.add(`todo:${taskMatch[1]}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const taskMatch = taskBody.match(FINAL_WAVE_TASK_PATTERN)
|
||||
if (taskMatch?.[1]) {
|
||||
checkedKeys.add(`final-wave:${taskMatch[1].toLowerCase()}`)
|
||||
}
|
||||
}
|
||||
|
||||
return checkedKeys
|
||||
}
|
||||
|
||||
function readCheckedTaskKeysFromPlan(planPath: string): Set<string> {
|
||||
if (!existsSync(planPath)) {
|
||||
return new Set<string>()
|
||||
}
|
||||
|
||||
try {
|
||||
return parseCheckedTopLevelTaskKeys(readFileSync(planPath, "utf-8"))
|
||||
} catch {
|
||||
return new Set<string>()
|
||||
}
|
||||
}
|
||||
|
||||
export function createToolExecuteAfterHandler(input: {
|
||||
ctx: PluginInput
|
||||
pendingFilePaths: Map<string, string>
|
||||
pendingTaskRefs: Map<string, PendingTaskRef>
|
||||
pendingPlanSnapshots?: Map<string, string>
|
||||
autoCommit: boolean
|
||||
getState: (sessionID: string) => SessionState
|
||||
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
|
||||
}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput | undefined) => Promise<void> {
|
||||
const { ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState } = input
|
||||
const { ctx, pendingFilePaths, pendingTaskRefs, pendingPlanSnapshots, autoCommit, getState } = input
|
||||
const resolveIsCallerOrchestrator = input.isCallerOrchestrator ?? ((sessionID) => isCallerOrchestrator(sessionID, ctx.client))
|
||||
return async (toolInput, toolOutput): Promise<void> => {
|
||||
// Guard against undefined output (e.g., from /review command - see issue #1035)
|
||||
@@ -48,12 +144,33 @@ export function createToolExecuteAfterHandler(input: {
|
||||
|
||||
if (isWriteOrEditToolName(toolInput.tool)) {
|
||||
let filePath = toolInput.callID ? pendingFilePaths.get(toolInput.callID) : undefined
|
||||
const planSnapshot = toolInput.callID && pendingPlanSnapshots
|
||||
? pendingPlanSnapshots.get(toolInput.callID)
|
||||
: undefined
|
||||
if (toolInput.callID) {
|
||||
pendingFilePaths.delete(toolInput.callID)
|
||||
pendingPlanSnapshots?.delete(toolInput.callID)
|
||||
}
|
||||
if (!filePath) {
|
||||
filePath = toolOutput.metadata?.filePath as string | undefined
|
||||
}
|
||||
|
||||
if (filePath && toolInput.sessionID) {
|
||||
const sessionWork = getWorkForSession(ctx.directory, toolInput.sessionID)
|
||||
if (sessionWork) {
|
||||
const planPath = resolveBoulderPlanPathForWork(ctx.directory, sessionWork)
|
||||
if (resolve(filePath) === resolve(planPath) && planSnapshot !== undefined) {
|
||||
const beforeCheckedKeys = parseCheckedTopLevelTaskKeys(planSnapshot)
|
||||
const afterCheckedKeys = readCheckedTaskKeysFromPlan(planPath)
|
||||
for (const taskKey of afterCheckedKeys) {
|
||||
if (!beforeCheckedKeys.has(taskKey)) {
|
||||
endTaskTimer(ctx.directory, sessionWork.work_id, taskKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filePath && !isSisyphusPath(filePath)) {
|
||||
toolOutput.output = (toolOutput.output || "") + DIRECT_WORK_REMINDER
|
||||
log(`[${HOOK_NAME}] Direct work reminder appended`, {
|
||||
@@ -100,7 +217,29 @@ export function createToolExecuteAfterHandler(input: {
|
||||
const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output)
|
||||
|
||||
if (boulderState) {
|
||||
const planPath = resolveBoulderPlanPath(ctx.directory, boulderState)
|
||||
const sessionWork = toolInput.sessionID
|
||||
? getWorkForSession(ctx.directory, toolInput.sessionID)
|
||||
: null
|
||||
const planPath = sessionWork
|
||||
? resolveBoulderPlanPathForWork(ctx.directory, sessionWork)
|
||||
: resolveBoulderPlanPath(ctx.directory, boulderState)
|
||||
const workScopedBoulderState = sessionWork
|
||||
? {
|
||||
...boulderState,
|
||||
active_plan: sessionWork.active_plan,
|
||||
plan_name: sessionWork.plan_name,
|
||||
status: sessionWork.status,
|
||||
started_at: sessionWork.started_at,
|
||||
ended_at: sessionWork.ended_at,
|
||||
elapsed_ms: sessionWork.elapsed_ms,
|
||||
updated_at: sessionWork.updated_at,
|
||||
session_ids: [...sessionWork.session_ids],
|
||||
session_origins: sessionWork.session_origins ? { ...sessionWork.session_origins } : {},
|
||||
agent: sessionWork.agent,
|
||||
worktree_path: sessionWork.worktree_path,
|
||||
task_sessions: sessionWork.task_sessions ? { ...sessionWork.task_sessions } : {},
|
||||
}
|
||||
: boulderState
|
||||
const progress = getPlanProgress(planPath)
|
||||
const {
|
||||
currentTask,
|
||||
@@ -112,7 +251,7 @@ export function createToolExecuteAfterHandler(input: {
|
||||
: null
|
||||
const sessionState = toolInput.sessionID ? getState(toolInput.sessionID) : undefined
|
||||
|
||||
const lineageSessionIDs = boulderState.session_ids
|
||||
const lineageSessionIDs = sessionWork?.session_ids ?? boulderState.session_ids
|
||||
const subagentSessionId = await validateSubagentSessionId({
|
||||
client: ctx.client,
|
||||
sessionID: extractedSessionId,
|
||||
@@ -120,14 +259,28 @@ export function createToolExecuteAfterHandler(input: {
|
||||
})
|
||||
|
||||
if (currentTask && subagentSessionId && !shouldSkipTaskSessionUpdate) {
|
||||
upsertTaskSessionState(ctx.directory, {
|
||||
taskKey: currentTask.key,
|
||||
taskLabel: currentTask.label,
|
||||
taskTitle: currentTask.title,
|
||||
sessionId: subagentSessionId,
|
||||
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
|
||||
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
|
||||
})
|
||||
if (sessionWork) {
|
||||
startTaskTimer(ctx.directory, sessionWork.work_id, {
|
||||
taskKey: currentTask.key,
|
||||
taskLabel: currentTask.label,
|
||||
taskTitle: currentTask.title,
|
||||
sessionId: subagentSessionId,
|
||||
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
|
||||
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
|
||||
})
|
||||
if (isTrackedTaskChecked(planPath, currentTask.key)) {
|
||||
endTaskTimer(ctx.directory, sessionWork.work_id, currentTask.key)
|
||||
}
|
||||
} else {
|
||||
upsertTaskSessionState(ctx.directory, {
|
||||
taskKey: currentTask.key,
|
||||
taskLabel: currentTask.label,
|
||||
taskTitle: currentTask.title,
|
||||
sessionId: subagentSessionId,
|
||||
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
|
||||
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const preferredSessionId = resolvePreferredSessionId(
|
||||
@@ -155,11 +308,11 @@ export function createToolExecuteAfterHandler(input: {
|
||||
}
|
||||
|
||||
const leadReminder = shouldPauseForApproval
|
||||
? buildFinalWaveApprovalReminder(boulderState.plan_name, progress, preferredSessionId)
|
||||
: buildCompletionGate(boulderState.plan_name, preferredSessionId)
|
||||
? buildFinalWaveApprovalReminder(workScopedBoulderState.plan_name, progress, preferredSessionId)
|
||||
: buildCompletionGate(workScopedBoulderState.plan_name, preferredSessionId)
|
||||
const followupReminder = shouldPauseForApproval
|
||||
? null
|
||||
: buildOrchestratorReminder(boulderState.plan_name, progress, preferredSessionId, autoCommit, false)
|
||||
: buildOrchestratorReminder(workScopedBoulderState.plan_name, progress, preferredSessionId, autoCommit, false)
|
||||
|
||||
toolOutput.output = `
|
||||
<system-reminder>
|
||||
@@ -181,8 +334,8 @@ ${
|
||||
? ""
|
||||
: `<system-reminder>\n${followupReminder}\n</system-reminder>`
|
||||
}`
|
||||
log(`[${HOOK_NAME}] Output transformed for orchestrator mode (boulder)`, {
|
||||
plan: boulderState.plan_name,
|
||||
log(`[${HOOK_NAME}] Output transformed for orchestrator mode (boulder)`, {
|
||||
plan: workScopedBoulderState.plan_name,
|
||||
progress: `${progress.completed}/${progress.total}`,
|
||||
fileCount: gitStats.length,
|
||||
preferredSessionId,
|
||||
|
||||
@@ -2,23 +2,69 @@ import { log } from "../../shared/logger"
|
||||
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
|
||||
import { isCallerOrchestrator } from "../../shared/session-utils"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { readBoulderState, readCurrentTopLevelTask, resolveBoulderPlanPath } from "../../features/boulder-state"
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { resolve } from "node:path"
|
||||
import { getWorkForSession, readBoulderState, readCurrentTopLevelTask, resolveBoulderPlanPath, resolveBoulderPlanPathForWork } from "../../features/boulder-state"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { ORCHESTRATOR_DELEGATION_REQUIRED, SINGLE_TASK_DIRECTIVE } from "./system-reminder-templates"
|
||||
import { isSisyphusPath } from "./sisyphus-path"
|
||||
import type { PendingTaskRef, TrackedTopLevelTaskRef } from "./types"
|
||||
import { isWriteOrEditToolName } from "./write-edit-tool-policy"
|
||||
|
||||
const TASK_SECTION_HEADER_PATTERN = /^##\s*1\.\s*TASK\s*$/i
|
||||
const TODO_TASK_LINE_PATTERN = /^(?:[-*]\s*\[\s*\]\s*)?(\d+)\.\s+(.+)$/
|
||||
const FINAL_WAVE_TASK_LINE_PATTERN = /^(?:[-*]\s*\[\s*\]\s*)?(F\d+)\.\s+(.+)$/i
|
||||
|
||||
function parseTrackedTaskFromPrompt(prompt: string): TrackedTopLevelTaskRef | null {
|
||||
const lines = prompt.split(/\r?\n/)
|
||||
const taskHeaderIndex = lines.findIndex((line) => TASK_SECTION_HEADER_PATTERN.test(line.trim()))
|
||||
if (taskHeaderIndex < 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const startIndex = taskHeaderIndex + 1
|
||||
const endIndex = Math.min(lines.length, startIndex + 5)
|
||||
for (let index = startIndex; index < endIndex; index += 1) {
|
||||
const candidate = lines[index]?.trim()
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
|
||||
const finalWaveMatch = candidate.match(FINAL_WAVE_TASK_LINE_PATTERN)
|
||||
if (finalWaveMatch?.[1] && finalWaveMatch[2]) {
|
||||
const label = finalWaveMatch[1].toUpperCase()
|
||||
return {
|
||||
key: `final-wave:${label.toLowerCase()}`,
|
||||
label,
|
||||
title: finalWaveMatch[2].trim(),
|
||||
}
|
||||
}
|
||||
|
||||
const todoMatch = candidate.match(TODO_TASK_LINE_PATTERN)
|
||||
if (todoMatch?.[1] && todoMatch[2]) {
|
||||
const label = todoMatch[1]
|
||||
return {
|
||||
key: `todo:${label}`,
|
||||
label,
|
||||
title: todoMatch[2].trim(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function createToolExecuteBeforeHandler(input: {
|
||||
ctx: PluginInput
|
||||
pendingFilePaths: Map<string, string>
|
||||
pendingTaskRefs: Map<string, PendingTaskRef>
|
||||
pendingPlanSnapshots?: Map<string, string>
|
||||
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
|
||||
}): (
|
||||
toolInput: { tool: string; sessionID?: string; callID?: string },
|
||||
toolOutput: { args: Record<string, unknown>; message?: string }
|
||||
) => Promise<void> {
|
||||
const { ctx, pendingFilePaths, pendingTaskRefs } = input
|
||||
const { ctx, pendingFilePaths, pendingTaskRefs, pendingPlanSnapshots } = input
|
||||
const resolveIsCallerOrchestrator = input.isCallerOrchestrator ?? ((sessionID) => isCallerOrchestrator(sessionID, ctx.client))
|
||||
|
||||
function trackTask(callID: string, task: TrackedTopLevelTaskRef): void {
|
||||
@@ -34,11 +80,35 @@ export function createToolExecuteBeforeHandler(input: {
|
||||
// Warn-only policy: Atlas guides orchestrators toward delegation but doesn't block, allowing flexibility for urgent fixes
|
||||
if (isWriteOrEditToolName(toolInput.tool)) {
|
||||
const filePath = (toolOutput.args.filePath ?? toolOutput.args.path ?? toolOutput.args.file) as string | undefined
|
||||
if (filePath && !isSisyphusPath(filePath)) {
|
||||
// Store filePath for use in tool.execute.after
|
||||
if (toolInput.callID) {
|
||||
pendingFilePaths.set(toolInput.callID, filePath)
|
||||
if (!filePath || !toolInput.callID) {
|
||||
return
|
||||
}
|
||||
|
||||
// Store filePath for use in tool.execute.after
|
||||
pendingFilePaths.set(toolInput.callID, filePath)
|
||||
|
||||
const sessionID = toolInput.sessionID
|
||||
const sessionWork = sessionID
|
||||
? getWorkForSession(ctx.directory, sessionID)
|
||||
: null
|
||||
const state = sessionWork ? null : readBoulderState(ctx.directory)
|
||||
const planPath = sessionWork
|
||||
? resolveBoulderPlanPathForWork(ctx.directory, sessionWork)
|
||||
: state
|
||||
? resolveBoulderPlanPath(ctx.directory, state)
|
||||
: null
|
||||
|
||||
if (planPath && resolve(filePath) === resolve(planPath) && pendingPlanSnapshots) {
|
||||
try {
|
||||
if (existsSync(planPath)) {
|
||||
pendingPlanSnapshots.set(toolInput.callID, readFileSync(planPath, "utf-8"))
|
||||
}
|
||||
} catch {
|
||||
pendingPlanSnapshots.delete(toolInput.callID)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSisyphusPath(filePath)) {
|
||||
const warning = ORCHESTRATOR_DELEGATION_REQUIRED.replace("$FILE_PATH", filePath)
|
||||
toolOutput.message = (toolOutput.message || "") + warning
|
||||
log(`[${HOOK_NAME}] Injected delegation warning for direct file modification`, {
|
||||
@@ -60,33 +130,48 @@ export function createToolExecuteBeforeHandler(input: {
|
||||
reason: "explicit_resume",
|
||||
})
|
||||
} else {
|
||||
const prompt = typeof toolOutput.args.prompt === "string" ? toolOutput.args.prompt : ""
|
||||
const taskFromPrompt = parseTrackedTaskFromPrompt(prompt)
|
||||
const boulderState = readBoulderState(ctx.directory)
|
||||
const currentTask = boulderState
|
||||
? readCurrentTopLevelTask(resolveBoulderPlanPath(ctx.directory, boulderState))
|
||||
: null
|
||||
if (currentTask) {
|
||||
const task = {
|
||||
key: currentTask.key,
|
||||
label: currentTask.label,
|
||||
title: currentTask.title,
|
||||
const resolvedTask = taskFromPrompt ?? (currentTask
|
||||
? {
|
||||
key: currentTask.key,
|
||||
label: currentTask.label,
|
||||
title: currentTask.title,
|
||||
}
|
||||
: null)
|
||||
if (resolvedTask) {
|
||||
if (!taskFromPrompt) {
|
||||
log(`[${HOOK_NAME}] TASK section parse failed; falling back to current top-level task`, {
|
||||
sessionID: toolInput.sessionID,
|
||||
callID: toolInput.callID,
|
||||
})
|
||||
}
|
||||
const trackedTask = {
|
||||
key: resolvedTask.key,
|
||||
label: resolvedTask.label,
|
||||
title: resolvedTask.title,
|
||||
}
|
||||
const hasExistingClaim = [...pendingTaskRefs.values()].some((pendingTaskRef) => (
|
||||
pendingTaskRef.kind === "track" && pendingTaskRef.task.key === task.key
|
||||
pendingTaskRef.kind === "track" && pendingTaskRef.task.key === trackedTask.key
|
||||
))
|
||||
|
||||
if (hasExistingClaim) {
|
||||
pendingTaskRefs.set(toolInput.callID, {
|
||||
kind: "skip",
|
||||
reason: "ambiguous_task_key",
|
||||
task,
|
||||
task: trackedTask,
|
||||
})
|
||||
log(`[${HOOK_NAME}] Skipping task session persistence for ambiguous task key`, {
|
||||
sessionID: toolInput.sessionID,
|
||||
callID: toolInput.callID,
|
||||
taskKey: task.key,
|
||||
taskKey: trackedTask.key,
|
||||
})
|
||||
} else {
|
||||
trackTask(toolInput.callID, task)
|
||||
trackTask(toolInput.callID, trackedTask)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,4 +48,5 @@ export interface SessionState {
|
||||
waitingForFinalWaveApproval?: boolean
|
||||
pendingFinalWaveTaskCount?: number
|
||||
approvedFinalWaveTaskCount?: number
|
||||
boulderCompletionNudgedAt?: Record<string, number>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit"]
|
||||
const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit", "hashline_edit"]
|
||||
|
||||
export function isWriteOrEditToolName(toolName: string): boolean {
|
||||
return WRITE_EDIT_TOOLS.includes(toolName)
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import { buildStartWorkContextInfo } from "./context-info-builder"
|
||||
import {
|
||||
addBoulderWork,
|
||||
createBoulderState,
|
||||
getBoulderFilePath,
|
||||
getWorkByPlanName,
|
||||
readBoulderState,
|
||||
writeBoulderState,
|
||||
} from "../../features/boulder-state"
|
||||
import * as boulderState from "../../features/boulder-state"
|
||||
|
||||
describe("buildStartWorkContextInfo", () => {
|
||||
let testDirectory = ""
|
||||
|
||||
function createPluginInput() {
|
||||
return {
|
||||
directory: testDirectory,
|
||||
} as never
|
||||
}
|
||||
|
||||
function writePlan(planName: string, content: string): string {
|
||||
const plansDirectory = join(testDirectory, ".sisyphus", "plans")
|
||||
mkdirSync(plansDirectory, { recursive: true })
|
||||
const planPath = join(plansDirectory, `${planName}.md`)
|
||||
writeFileSync(planPath, content)
|
||||
return planPath
|
||||
}
|
||||
|
||||
function readExistingState() {
|
||||
return readBoulderState(testDirectory)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
testDirectory = join(tmpdir(), `context-info-builder-${randomUUID()}`)
|
||||
mkdirSync(testDirectory, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(testDirectory)) {
|
||||
rmSync(testDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("lists multiple active works and asks agent to choose resume vs new when no explicit plan", () => {
|
||||
// given
|
||||
const clearSpy = spyOn(boulderState, "clearBoulderState")
|
||||
const planAPath = writePlan("plan-alpha", "## TODOs\n- [ ] 1. Alpha")
|
||||
const planBPath = writePlan("plan-beta", "## TODOs\n- [ ] 1. Beta")
|
||||
const initialState = createBoulderState(planAPath, "session-a", "atlas", "/tmp/worktree-a")
|
||||
writeBoulderState(testDirectory, initialState)
|
||||
addBoulderWork(testDirectory, {
|
||||
planPath: planBPath,
|
||||
sessionId: "session-b",
|
||||
agent: "atlas",
|
||||
worktreePath: "/tmp/worktree-b",
|
||||
})
|
||||
|
||||
// when
|
||||
const contextInfo = buildStartWorkContextInfo({
|
||||
ctx: createPluginInput(),
|
||||
explicitPlanName: null,
|
||||
existingState: readExistingState(),
|
||||
sessionId: "session-current",
|
||||
timestamp: "2026-05-11T00:00:00.000Z",
|
||||
activeAgent: "atlas",
|
||||
worktreePath: undefined,
|
||||
worktreeBlock: "",
|
||||
})
|
||||
|
||||
// then
|
||||
expect(contextInfo).toContain("plan-alpha")
|
||||
expect(contextInfo).toContain("plan-beta")
|
||||
expect(contextInfo).toContain("Use the Question tool")
|
||||
expect(clearSpy).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
test("auto-resumes when exactly one active work exists and no explicit plan", () => {
|
||||
// given
|
||||
const clearSpy = spyOn(boulderState, "clearBoulderState")
|
||||
const planPath = writePlan("single-active-plan", "## TODOs\n- [ ] 1. Single task")
|
||||
const initialState = createBoulderState(planPath, "session-a", "atlas", "/tmp/worktree-single")
|
||||
writeBoulderState(testDirectory, initialState)
|
||||
|
||||
// when
|
||||
const contextInfo = buildStartWorkContextInfo({
|
||||
ctx: createPluginInput(),
|
||||
explicitPlanName: null,
|
||||
existingState: readExistingState(),
|
||||
sessionId: "session-current",
|
||||
timestamp: "2026-05-11T00:00:00.000Z",
|
||||
activeAgent: "atlas",
|
||||
worktreePath: undefined,
|
||||
worktreeBlock: "",
|
||||
})
|
||||
|
||||
// then
|
||||
expect(contextInfo).toContain("RESUMING existing work")
|
||||
expect(contextInfo).toContain("single-active-plan")
|
||||
expect(contextInfo).not.toContain("Use the Question tool")
|
||||
expect(clearSpy).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
test("explicit plan selects matching work only and never clears boulder state", () => {
|
||||
// given
|
||||
const clearSpy = spyOn(boulderState, "clearBoulderState")
|
||||
const planAPath = writePlan("explicit-plan-a", "## TODOs\n- [ ] 1. A")
|
||||
const planBPath = writePlan("explicit-plan-b", "## TODOs\n- [ ] 1. B")
|
||||
const initialState = createBoulderState(planAPath, "session-a", "atlas", "/tmp/worktree-a")
|
||||
writeBoulderState(testDirectory, initialState)
|
||||
addBoulderWork(testDirectory, {
|
||||
planPath: planBPath,
|
||||
sessionId: "session-b",
|
||||
agent: "atlas",
|
||||
worktreePath: "/tmp/worktree-b",
|
||||
})
|
||||
|
||||
// when
|
||||
const contextInfo = buildStartWorkContextInfo({
|
||||
ctx: createPluginInput(),
|
||||
explicitPlanName: "explicit-plan-a",
|
||||
existingState: readExistingState(),
|
||||
sessionId: "session-current",
|
||||
timestamp: "2026-05-11T00:00:00.000Z",
|
||||
activeAgent: "atlas",
|
||||
worktreePath: "/tmp/worktree-a",
|
||||
worktreeBlock: "",
|
||||
})
|
||||
|
||||
// then
|
||||
expect(contextInfo).toContain("explicit-plan-a")
|
||||
expect(contextInfo).not.toContain("explicit-plan-b")
|
||||
expect(clearSpy).toHaveBeenCalledTimes(0)
|
||||
|
||||
const selectedWork = getWorkByPlanName(testDirectory, "explicit-plan-a", { worktreePath: "/tmp/worktree-a" })
|
||||
const nextState = readBoulderState(testDirectory)
|
||||
expect(selectedWork).not.toBeNull()
|
||||
expect(nextState?.active_work_id).toBe(selectedWork?.work_id)
|
||||
})
|
||||
|
||||
test("falls back to auto-select latest plan when no works exist", () => {
|
||||
// given
|
||||
const clearSpy = spyOn(boulderState, "clearBoulderState")
|
||||
const coldStartPlanPath = writePlan("cold-start-plan", "## TODOs\n- [ ] 1. Cold start")
|
||||
|
||||
// when
|
||||
const contextInfo = buildStartWorkContextInfo({
|
||||
ctx: createPluginInput(),
|
||||
explicitPlanName: null,
|
||||
existingState: null,
|
||||
sessionId: "session-current",
|
||||
timestamp: "2026-05-11T00:00:00.000Z",
|
||||
activeAgent: "atlas",
|
||||
worktreePath: undefined,
|
||||
worktreeBlock: "",
|
||||
})
|
||||
|
||||
// then
|
||||
expect(contextInfo).toContain("Auto-Selected Plan")
|
||||
expect(contextInfo).toContain("cold-start-plan")
|
||||
expect(contextInfo).toContain(coldStartPlanPath)
|
||||
expect(existsSync(getBoulderFilePath(testDirectory))).toBe(true)
|
||||
expect(clearSpy).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
test("keeps existing works when explicit new plan is started", () => {
|
||||
// given
|
||||
writePlan("work-a", "## TODOs\n- [ ] 1. Work A")
|
||||
const workBPath = writePlan("work-b", "## TODOs\n- [ ] 1. Work B")
|
||||
writePlan("new-plan-c", "## TODOs\n- [ ] 1. Work C")
|
||||
|
||||
const initialState = createBoulderState(
|
||||
join(testDirectory, ".sisyphus", "plans", "work-a.md"),
|
||||
"session-a",
|
||||
"atlas",
|
||||
"/tmp/worktree-a",
|
||||
)
|
||||
writeBoulderState(testDirectory, initialState)
|
||||
|
||||
const workAId = initialState.active_work_id!
|
||||
const withSecondWork = addBoulderWork(testDirectory, {
|
||||
planPath: workBPath,
|
||||
sessionId: "session-b",
|
||||
agent: "atlas",
|
||||
worktreePath: "/tmp/worktree-b",
|
||||
})
|
||||
expect(withSecondWork).not.toBeNull()
|
||||
const workBId = Object.keys(withSecondWork!.works!).find((workId) => workId !== workAId)
|
||||
expect(workBId).toBeDefined()
|
||||
|
||||
// when
|
||||
buildStartWorkContextInfo({
|
||||
ctx: createPluginInput(),
|
||||
explicitPlanName: "new-plan-c",
|
||||
existingState: readExistingState(),
|
||||
sessionId: "session-c",
|
||||
timestamp: "2026-05-11T00:00:00.000Z",
|
||||
activeAgent: "atlas",
|
||||
worktreePath: undefined,
|
||||
worktreeBlock: "",
|
||||
})
|
||||
|
||||
// then
|
||||
const nextState = readBoulderState(testDirectory)
|
||||
const workIds = Object.keys(nextState?.works ?? {})
|
||||
expect(workIds.length).toBe(3)
|
||||
expect(workIds).toContain(workAId)
|
||||
expect(workIds).toContain(workBId!)
|
||||
const workC = getWorkByPlanName(testDirectory, "new-plan-c")
|
||||
expect(workC).not.toBeNull()
|
||||
expect(workIds).toContain(workC!.work_id)
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,17 @@
|
||||
import { statSync } from "node:fs"
|
||||
import {
|
||||
appendSessionId,
|
||||
clearBoulderState,
|
||||
addBoulderWork,
|
||||
createBoulderState,
|
||||
findPrometheusPlans,
|
||||
getActiveWorks,
|
||||
getPlanName,
|
||||
getPlanProgress,
|
||||
getWorkByPlanName,
|
||||
getWorkResumeOptions,
|
||||
readBoulderState,
|
||||
resolveBoulderPlanPath,
|
||||
selectActiveWork,
|
||||
writeBoulderState,
|
||||
} from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
@@ -44,19 +48,14 @@ function findPlanByName(plans: string[], requestedName: string): string | null {
|
||||
return normalizedPartialMatch || null
|
||||
}
|
||||
|
||||
function buildAutoSelectedPlanContext(params: {
|
||||
function buildAutoSelectedPlanContextInfoOnly(params: {
|
||||
planPath: string
|
||||
sessionId: string
|
||||
timestamp: string
|
||||
activeAgent: string
|
||||
worktreePath: string | undefined
|
||||
worktreeBlock: string
|
||||
directory: string
|
||||
}): string {
|
||||
const { planPath, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory } = params
|
||||
const { planPath, sessionId, timestamp, worktreeBlock } = params
|
||||
const progress = getPlanProgress(planPath)
|
||||
const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath)
|
||||
writeBoulderState(directory, newState)
|
||||
|
||||
return `
|
||||
## Auto-Selected Plan
|
||||
@@ -71,6 +70,27 @@ ${worktreeBlock}
|
||||
boulder.json has been created. Read the plan and begin execution.`
|
||||
}
|
||||
|
||||
function buildAutoSelectedPlanContextWithStateInit(params: {
|
||||
planPath: string
|
||||
sessionId: string
|
||||
timestamp: string
|
||||
activeAgent: string
|
||||
worktreePath: string | undefined
|
||||
worktreeBlock: string
|
||||
directory: string
|
||||
}): string {
|
||||
const { planPath, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory } = params
|
||||
const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath)
|
||||
writeBoulderState(directory, newState)
|
||||
|
||||
return buildAutoSelectedPlanContextInfoOnly({
|
||||
planPath,
|
||||
sessionId,
|
||||
timestamp,
|
||||
worktreeBlock,
|
||||
})
|
||||
}
|
||||
|
||||
function buildMissingPlanContext(explicitPlanName: string, allPlans: string[]): string {
|
||||
const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete)
|
||||
if (incompletePlans.length > 0) {
|
||||
@@ -99,9 +119,73 @@ Ask the user which plan to work on.`
|
||||
No incomplete plans available. Create a new plan using the Prometheus agent.`
|
||||
}
|
||||
|
||||
function formatElapsedHuman(elapsedMs: number | undefined): string {
|
||||
if (typeof elapsedMs !== "number" || elapsedMs <= 0) {
|
||||
return "running"
|
||||
}
|
||||
|
||||
const totalSeconds = Math.floor(elapsedMs / 1000)
|
||||
const seconds = totalSeconds % 60
|
||||
const totalMinutes = Math.floor(totalSeconds / 60)
|
||||
const minutes = totalMinutes % 60
|
||||
const hours = Math.floor(totalMinutes / 60)
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${seconds}s`
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds}s`
|
||||
}
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
function buildMultipleActiveWorksContext(params: {
|
||||
resumeOptions: ReturnType<typeof getWorkResumeOptions>
|
||||
sessionId: string
|
||||
timestamp: string
|
||||
}): string {
|
||||
const { resumeOptions, sessionId, timestamp } = params
|
||||
const optionList = resumeOptions
|
||||
.map((option, index) => `${index + 1}. ${option.plan_name} - ${option.progress.completed}/${option.progress.total} (${option.progress.total === 0 ? 0 : Math.floor((option.progress.completed / option.progress.total) * 100)}%) - elapsed: ${formatElapsedHuman(option.elapsed_ms)} - worktree: ${option.worktree_path ?? "current directory"} - sessions: ${option.session_count}`)
|
||||
.join("\n")
|
||||
|
||||
return `
|
||||
<system-reminder>
|
||||
## Multiple Active Works Found
|
||||
|
||||
Current Time: ${timestamp}
|
||||
Session ID: ${sessionId}
|
||||
|
||||
${optionList}
|
||||
|
||||
Use the Question tool to ask the user which plan to resume.
|
||||
- If the user chooses one option, run /start-work {plan-name} for that plan.
|
||||
- If the user chooses to start a new plan, proceed with cold-start auto-selection flow.
|
||||
</system-reminder>`
|
||||
}
|
||||
|
||||
function createNewWorkOrInitialize(params: {
|
||||
directory: string
|
||||
planPath: string
|
||||
sessionId: string
|
||||
activeAgent: string
|
||||
worktreePath: string | undefined
|
||||
}): void {
|
||||
const { directory, planPath, sessionId, activeAgent, worktreePath } = params
|
||||
const created = addBoulderWork(directory, {
|
||||
planPath,
|
||||
sessionId,
|
||||
agent: activeAgent,
|
||||
worktreePath,
|
||||
})
|
||||
|
||||
if (!created) {
|
||||
const initializedState = createBoulderState(planPath, sessionId, activeAgent, worktreePath)
|
||||
writeBoulderState(directory, initializedState)
|
||||
}
|
||||
}
|
||||
|
||||
function buildExplicitPlanContext(params: {
|
||||
explicitPlanName: string
|
||||
existingState: ReturnType<typeof readBoulderState>
|
||||
sessionId: string
|
||||
timestamp: string
|
||||
activeAgent: string
|
||||
@@ -109,9 +193,24 @@ function buildExplicitPlanContext(params: {
|
||||
worktreeBlock: string
|
||||
directory: string
|
||||
}): string {
|
||||
const { explicitPlanName, existingState, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory } = params
|
||||
const { explicitPlanName, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory } = params
|
||||
log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { sessionID: sessionId })
|
||||
|
||||
const matchedWork = getWorkByPlanName(directory, explicitPlanName, { worktreePath })
|
||||
if (matchedWork) {
|
||||
const selectedState = selectActiveWork(directory, matchedWork.work_id)
|
||||
if (selectedState) {
|
||||
return buildExistingSessionContext({
|
||||
existingState: selectedState,
|
||||
sessionId,
|
||||
activeAgent,
|
||||
worktreePath,
|
||||
worktreeBlock,
|
||||
directory,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const allPlans = findPrometheusPlans(directory)
|
||||
const matchedPlan = findPlanByName(allPlans, explicitPlanName)
|
||||
if (!matchedPlan) {
|
||||
@@ -127,18 +226,19 @@ function buildExplicitPlanContext(params: {
|
||||
All ${progress.total} tasks are done. Create a new plan using the Prometheus agent.`
|
||||
}
|
||||
|
||||
if (existingState) {
|
||||
clearBoulderState(directory)
|
||||
}
|
||||
createNewWorkOrInitialize({
|
||||
directory,
|
||||
planPath: matchedPlan,
|
||||
sessionId,
|
||||
activeAgent,
|
||||
worktreePath,
|
||||
})
|
||||
|
||||
return buildAutoSelectedPlanContext({
|
||||
return buildAutoSelectedPlanContextInfoOnly({
|
||||
planPath: matchedPlan,
|
||||
sessionId,
|
||||
timestamp,
|
||||
activeAgent,
|
||||
worktreePath,
|
||||
worktreeBlock,
|
||||
directory,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -241,7 +341,7 @@ function buildPlanDiscoveryContext(params: {
|
||||
}
|
||||
|
||||
if (incompletePlans.length === 1) {
|
||||
return contextInfo + buildAutoSelectedPlanContext({
|
||||
return contextInfo + buildAutoSelectedPlanContextWithStateInit({
|
||||
planPath: incompletePlans[0],
|
||||
sessionId,
|
||||
timestamp,
|
||||
@@ -287,11 +387,48 @@ export function buildStartWorkContextInfo(params: {
|
||||
}): string {
|
||||
const { ctx, explicitPlanName, existingState, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock } = params
|
||||
|
||||
const resumeOptions = getWorkResumeOptions(ctx.directory)
|
||||
.filter((option) => option.status === "active" || option.status === "paused")
|
||||
|
||||
if (!explicitPlanName && resumeOptions.length > 1) {
|
||||
return buildMultipleActiveWorksContext({
|
||||
resumeOptions,
|
||||
sessionId,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
if (!explicitPlanName && resumeOptions.length === 1) {
|
||||
const onlyOption = resumeOptions[0]
|
||||
const selectedState = selectActiveWork(ctx.directory, onlyOption.work_id)
|
||||
if (selectedState) {
|
||||
return buildExistingSessionContext({
|
||||
existingState: selectedState,
|
||||
sessionId,
|
||||
activeAgent,
|
||||
worktreePath,
|
||||
worktreeBlock,
|
||||
directory: ctx.directory,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!explicitPlanName && resumeOptions.length === 0 && getActiveWorks(ctx.directory).length === 0) {
|
||||
return buildPlanDiscoveryContext({
|
||||
contextInfo: "",
|
||||
sessionId,
|
||||
timestamp,
|
||||
activeAgent,
|
||||
worktreePath,
|
||||
worktreeBlock,
|
||||
directory: ctx.directory,
|
||||
})
|
||||
}
|
||||
|
||||
let contextInfo = ""
|
||||
if (explicitPlanName) {
|
||||
contextInfo = buildExplicitPlanContext({
|
||||
explicitPlanName,
|
||||
existingState,
|
||||
sessionId,
|
||||
timestamp,
|
||||
activeAgent,
|
||||
|
||||
Reference in New Issue
Block a user