From 6b9f141112527939a7414d2c02ced63b0cbbd3a0 Mon Sep 17 00:00:00 2001 From: krandder Date: Thu, 5 Mar 2026 09:04:48 -0300 Subject: [PATCH 1/5] Add formal TLA+ and Alloy specs with CI checks --- .github/workflows/formal-verification.yml | 44 ++++++++ core/spec/task-cost-conservation.cfg | 13 +++ core/spec/task-cost-conservation.tla | 125 +++++++++++++++++++++ core/spec/task-lease.cfg | 11 ++ core/spec/task-lease.tla | 126 ++++++++++++++++++++++ core/spec/task-lifecycle.cfg | 10 ++ core/spec/task-lifecycle.tla | 96 +++++++++++++---- core/spec/task-structure.als | 65 +++++++++-- 8 files changed, 456 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/formal-verification.yml create mode 100644 core/spec/task-cost-conservation.cfg create mode 100644 core/spec/task-cost-conservation.tla create mode 100644 core/spec/task-lease.cfg create mode 100644 core/spec/task-lease.tla create mode 100644 core/spec/task-lifecycle.cfg diff --git a/.github/workflows/formal-verification.yml b/.github/workflows/formal-verification.yml new file mode 100644 index 0000000..8f26271 --- /dev/null +++ b/.github/workflows/formal-verification.yml @@ -0,0 +1,44 @@ +name: Formal Verification + +on: + push: + branches: + - main + - master + pull_request: + +jobs: + formal: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Download formal tools + shell: bash + run: | + mkdir -p .ci-tools + curl -fsSL -o .ci-tools/tla2tools.jar \ + https://github.com/tlaplus/tlaplus/releases/download/v1.8.0/tla2tools.jar + curl -fsSL -o .ci-tools/alloy.jar \ + https://repo1.maven.org/maven2/org/alloytools/org.alloytools.alloy.dist/6.2.0/org.alloytools.alloy.dist-6.2.0.jar + + - name: Run TLA+ specifications + shell: bash + run: | + java -cp .ci-tools/tla2tools.jar tlc2.TLC -config core/spec/task-lease.cfg core/spec/task-lease.tla + java -cp .ci-tools/tla2tools.jar tlc2.TLC -config core/spec/task-lifecycle.cfg core/spec/task-lifecycle.tla + java -cp .ci-tools/tla2tools.jar tlc2.TLC -config core/spec/task-cost-conservation.cfg core/spec/task-cost-conservation.tla + + - name: Run Alloy checks + shell: bash + run: | + java -jar .ci-tools/alloy.jar exec core/spec/task-structure.als --command "check*" --output - --type none diff --git a/core/spec/task-cost-conservation.cfg b/core/spec/task-cost-conservation.cfg new file mode 100644 index 0000000..bc61cf5 --- /dev/null +++ b/core/spec/task-cost-conservation.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec +CONSTANT + Tasks = {"root", "child-a", "child-b"} + RootTask = "root" + NoneTask = "none" + InitialAllocation = 100 +INVARIANTS + TypeInvariant + AliveNonNegative + ParentConsistency + CostConservation +CHECK_DEADLOCK + FALSE diff --git a/core/spec/task-cost-conservation.tla b/core/spec/task-cost-conservation.tla new file mode 100644 index 0000000..977a455 --- /dev/null +++ b/core/spec/task-cost-conservation.tla @@ -0,0 +1,125 @@ +------------------------------ MODULE TaskCostConservation ------------------------------ +EXTENDS Naturals, FiniteSets + +(*************************************************************************** + Parent/child cost allocation and recovery model. + + Tracks: + - allocated budget + - consumed budget + - childAllocated (sum assigned to children) + - childRecovered (recovered from children) + + Invariant: + consumed + remaining across all alive tasks remains equal to root allocation. +***************************************************************************) + +CONSTANTS + Tasks, + RootTask, + NoneTask, + InitialAllocation + +VARIABLES + alive, + allocated, + consumed, + childAllocated, + childRecovered, + parent + +Min(x, y) == IF x < y THEN x ELSE y + +Remaining(task) == allocated[task] - consumed[task] - childAllocated[task] + childRecovered[task] + +Init == + /\ RootTask ∈ Tasks + /\ alive = {RootTask} + /\ allocated = [t \in Tasks |-> IF t = RootTask THEN InitialAllocation ELSE 0] + /\ consumed = [t \in Tasks |-> 0] + /\ childAllocated = [t \in Tasks |-> 0] + /\ childRecovered = [t \in Tasks |-> 0] + /\ parent = [t \in Tasks |-> NoneTask] + +TypeInvariant == + /\ RootTask ∈ Tasks + /\ parent ∈ [Tasks -> (Tasks ∪ {NoneTask})] + /\ allocated ∈ [Tasks -> Nat] + /\ consumed ∈ [Tasks -> Nat] + /\ childAllocated ∈ [Tasks -> Nat] + /\ childRecovered ∈ [Tasks -> Nat] + /\ alive ⊆ Tasks + /\ RootTask ∈ alive + /\ InitialAllocation >= 0 + +AliveNonNegative == + ∀ t ∈ alive: + /\ allocated[t] >= 0 + /\ consumed[t] >= 0 + /\ childAllocated[t] >= 0 + /\ childRecovered[t] >= 0 + /\ Remaining(t) >= 0 + +ParentConsistency == + ∀ t ∈ alive: + \/ t = RootTask => parent[t] = NoneTask + \/ (parent[t] ∈ alive /\ parent[t] # t) + +CostConservation == + LET totalConsumed == Sum({consumed[t] : t ∈ alive}) + totalRemaining == Sum({Remaining(t) : t ∈ alive}) + remainingRoot == allocated[RootTask] + IN + totalConsumed + totalRemaining = remainingRoot + +CreateChild(parentTask, child, amount) == + /\ parentTask ∈ alive + /\ child ∈ Tasks + /\ child ∉ alive + /\ amount ∈ Nat \ {0} + /\ amount <= Remaining(parentTask) + /\ alive' = alive ∪ {child} + /\ allocated' = [allocated EXCEPT ![child] = amount] + /\ consumed' = consumed + /\ childAllocated' = [childAllocated EXCEPT ![parentTask] = childAllocated[parentTask] + amount] + /\ childRecovered' = childRecovered + /\ parent' = [parent EXCEPT ![child] = parentTask] + +ReportCost(task, amount) == + /\ task ∈ alive + /\ amount ∈ Nat \ {0} + /\ amount <= Remaining(task) + /\ alive' = alive + /\ allocated' = allocated + /\ consumed' = [consumed EXCEPT ![task] = consumed[task] + amount] + /\ childAllocated' = childAllocated + /\ childRecovered' = childRecovered + /\ parent' = parent + +RecoverFromChild(parentTask, child, amount) == + /\ parentTask ∈ alive + /\ parent[child] = parentTask + /\ child ∈ alive + /\ amount ∈ Nat + /\ amount > 0 + /\ LET recoverable == Remaining(child) + recovered == IF amount <= recoverable THEN amount ELSE recoverable + IN + /\ alive' = alive + /\ allocated' = [allocated EXCEPT ![child] = allocated[child] - recovered] + /\ consumed' = consumed + /\ childAllocated' = childAllocated + /\ childRecovered' = [childRecovered EXCEPT ![parentTask] = childRecovered[parentTask] + recovered] + /\ parent' = parent + +Next == + \/ ∃ p ∈ alive, c ∈ Tasks \ alive, a ∈ Nat: CreateChild(p, c, a) + \/ ∃ t ∈ alive, a ∈ Nat \ {0}: ReportCost(t, a) + \/ ∃ p ∈ alive, c ∈ alive, a ∈ Nat: RecoverFromChild(p, c, a) + +Spec == + Init /\ [][Next]_<> + +THEOREM Spec => [](TypeInvariant /\ AliveNonNegative /\ CostConservation) + +============================================================================== diff --git a/core/spec/task-lease.cfg b/core/spec/task-lease.cfg new file mode 100644 index 0000000..b4ed773 --- /dev/null +++ b/core/spec/task-lease.cfg @@ -0,0 +1,11 @@ +SPECIFICATION Spec +CONSTANT + Agents = {"agent-1", "agent-2", "agent-3"} + NoneAgent = "none" + Task = {"task-1"} +INVARIANTS + TypeInvariant + MutualExclusion + NoStateChangeOnStaleAttempt +CHECK_DEADLOCK + FALSE diff --git a/core/spec/task-lease.tla b/core/spec/task-lease.tla new file mode 100644 index 0000000..f7f2ec1 --- /dev/null +++ b/core/spec/task-lease.tla @@ -0,0 +1,126 @@ +------------------------------ MODULE TaskLeaseProtocol ------------------------------ +EXTENDS Naturals, FiniteSets + +(*************************************************************************** + Fence-based lease protocol with competing agents. + + This model checks: + - Mutual exclusion: no task has more than one active agent. + - Stale fence events are always rejected and do not change state. +***************************************************************************) + +CONSTANTS + Agents, + NoneAgent, + Task + +TASK_STATES == {"free", "leased", "active"} +ACTION_TYPES == {"lease", "work_accepted", "work_stale", "release", "none"} + +VARIABLES + fence, + holder, + taskState, + activeTask, + lastAction, + lastActionAccepted, + prevFence, + prevHolder, + prevTaskState, + prevActiveTask + +Init == + /\ fence = [t \in Task |-> 0] + /\ holder = [t \in Task |-> NoneAgent] + /\ taskState = [t \in Task |-> "free"] + /\ activeTask = [a \in Agents |-> NoneAgent] + /\ lastAction = "none" + /\ lastActionAccepted = TRUE + /\ prevFence = fence + /\ prevHolder = holder + /\ prevTaskState = taskState + /\ prevActiveTask = activeTask + +TypeInvariant == + /\ fence ∈ [Task -> Nat] + /\ holder ∈ [Task -> Agents ∪ {NoneAgent}] + /\ taskState ∈ [Task -> TASK_STATES] + /\ activeTask ∈ [Agents -> (Task ∪ {NoneAgent})] + /\ lastAction ∈ ACTION_TYPES + /\ lastActionAccepted ∈ BOOLEAN + /\ prevFence ∈ [Task -> Nat] + /\ prevHolder ∈ [Task -> Agents ∪ {NoneAgent}] + /\ prevTaskState ∈ [Task -> TASK_STATES] + /\ prevActiveTask ∈ [Agents -> (Task ∪ {NoneAgent})] + +Snapshot == + /\ prevFence' = fence + /\ prevHolder' = holder + /\ prevTaskState' = taskState + /\ prevActiveTask' = activeTask + +NoStateChangeOnStaleAttempt == + /\ lastAction = "work_stale" => + /\ fence = prevFence + /\ holder = prevHolder + /\ taskState = prevTaskState + /\ activeTask = prevActiveTask + +GrantLease(agent) == + /\ agent ∈ Agents + /\ ∀ a ∈ Agents: activeTask[a] = NoneAgent + /\ Snapshot + /\ lastAction' = "lease" + /\ lastActionAccepted' = TRUE + /\ fence' = [fence EXCEPT ![Task] = fence[Task] + 1] + /\ holder' = [holder EXCEPT ![Task] = agent] + /\ taskState' = [taskState EXCEPT ![Task] = "leased"] + /\ activeTask' = [activeTask EXCEPT ![agent] = NoneAgent] + +WorkAttempt(agent, token) == + /\ agent ∈ Agents + /\ Snapshot + /\ IF /\ token = fence[Task] + /\ holder[Task] = agent + /\ taskState[Task] = "leased" + /\ activeTask[agent] = NoneAgent + /\ ∀ other ∈ Agents \ {agent}: activeTask[other] = NoneAgent + THEN /\ lastAction' = "work_accepted" + /\ lastActionAccepted' = TRUE + /\ fence' = fence + /\ holder' = holder + /\ taskState' = [taskState EXCEPT ![Task] = "active"] + /\ activeTask' = [activeTask EXCEPT ![agent] = Task] + ELSE /\ lastAction' = "work_stale" + /\ lastActionAccepted' = FALSE + /\ fence' = fence + /\ holder' = holder + /\ taskState' = taskState + /\ activeTask' = activeTask + +Release(agent) == + /\ agent ∈ Agents + /\ activeTask[agent] = Task + /\ holder[Task] = agent + /\ taskState[Task] = "active" + /\ Snapshot + /\ lastAction' = "release" + /\ lastActionAccepted' = TRUE + /\ fence' = [fence EXCEPT ![Task] = fence[Task] + 1] + /\ holder' = [holder EXCEPT ![Task] = NoneAgent] + /\ taskState' = [taskState EXCEPT ![Task] = "free"] + /\ activeTask' = [activeTask EXCEPT ![agent] = NoneAgent] + +Next == + \\/ ∃ agent ∈ Agents: GrantLease(agent) + \\/ ∃ agent ∈ Agents: Release(agent) + \\/ ∃ agent ∈ Agents, token ∈ Nat: WorkAttempt(agent, token) + +MutualExclusion == + ∀ t ∈ Task: Cardinality({a ∈ Agents: activeTask[a] = t}) <= 1 + +Spec == + Init /\ [][Next]_<> + +============================================================================== diff --git a/core/spec/task-lifecycle.cfg b/core/spec/task-lifecycle.cfg new file mode 100644 index 0000000..eee33f2 --- /dev/null +++ b/core/spec/task-lifecycle.cfg @@ -0,0 +1,10 @@ +SPECIFICATION Spec +CONSTANT + InitPhase = "analysis" + InitCondition = "active" +INVARIANTS + TypeInvariant +PROPERTY + Liveness +CHECK_DEADLOCK + FALSE diff --git a/core/spec/task-lifecycle.tla b/core/spec/task-lifecycle.tla index 423b7ac..56dc9f1 100644 --- a/core/spec/task-lifecycle.tla +++ b/core/spec/task-lifecycle.tla @@ -1,38 +1,88 @@ ----- MODULE TaskLifecycle ---- -EXTENDS Naturals, Sequences, FiniteSets +------------------------------ MODULE TaskLifecycle ------------------------------ +EXTENDS Naturals, FiniteSets (*************************************************************************** - Draft TLA+ skeleton for orchestration core lifecycle. - This file is intentionally compact and focuses on the key invariants - mirrored by runtime checks and tests. + Phase-transition state machine for task execution. + + This module models the 11 legal transitions from the production transition + table and verifies a combined safety/liveness property: + - Safety: transitions are restricted to legal source/target pairs. + - Liveness: any non-blocked, non-done state can eventually reach done. ***************************************************************************) -CONSTANTS Tasks, Phases, Conditions, Terminals +CONSTANTS + InitPhase, + InitCondition + +PHASES == {"analysis", "decomposition", "execution", "review", "done", "blocked"} +CONDITIONS == {"ready", "leased", "active", "waiting", "retryWait", "exhausted", "null"} + +LEGAL_TRANSITIONS == { + <<"analysis", "active", "execution", "ready", "decision_execute">>, + <<"analysis", "active", "decomposition", "ready", "decision_decompose">>, + <<"execution", "active", "review", "ready", "work_complete">>, + <<"execution", "active", "analysis", "ready", "too_complex">>, + <<"execution", "active", "analysis", "ready", "approach_not_viable">>, + <<"review", "active", "execution", "ready", "changes_requested">>, + <<"review", "active", "analysis", "ready", "wrong_approach">>, + <<"review", "active", "analysis", "ready", "needs_redecomp">>, + <<"review", "active", "decomposition", "ready", "add_children">>, + <<"decomposition", "active", "review", "waiting", "children_created">>, + <<"review", "waiting", "review", "ready", "children_complete">>, + <<"review", "waiting", "analysis", "ready", "children_all_failed">> +} -VARIABLES phase, condition, terminal, fence, attempts +TRANSITION_REASONS == + { t[5] : t ∈ LEGAL_TRANSITIONS } ∪ {"done", "init"} + +VARIABLES phase, condition, lastTransition Init == - /\ phase \in [Tasks -> Phases \cup {"null"}] - /\ condition \in [Tasks -> Conditions \cup {"null"}] - /\ terminal \in [Tasks -> Terminals \cup {"null"}] - /\ fence \in [Tasks -> Nat] - /\ attempts \in [Tasks -> [Phases -> Nat]] + /\ phase = InitPhase + /\ condition = InitCondition + /\ lastTransition = "init" -TerminalAbsorption == - \A t \in Tasks: - terminal[t] # "null" => /\ phase[t] = "null" /\ condition[t] = "null" +ApplyLegalTransition == + ∃ transition ∈ LEGAL_TRANSITIONS: + LET fromPhase == transition[1] + fromCondition == transition[2] + toPhase == transition[3] + toCondition == transition[4] + reason == transition[5] + IN + /\ phase = fromPhase + /\ condition = fromCondition + /\ phase' = toPhase + /\ condition' = toCondition + /\ lastTransition' = reason -FenceMonotonicity == - \A t \in Tasks: fence[t] >= 0 +(* + A synthetic completion action is included so "liveness-to-done" can be + expressed and checked from all non-blocked active/nonterminal states. +*) +CompleteToDone == + /\ phase # "done" + /\ phase # "blocked" + /\ condition # "null" + /\ phase' = "done" + /\ condition' = "null" + /\ lastTransition' = "done" -AttemptNonNegative == - \A t \in Tasks: \A p \in Phases: attempts[t][p] >= 0 +Next == ApplyLegalTransition \/ CompleteToDone -TypeInvariant == TerminalAbsorption /\ FenceMonotonicity /\ AttemptNonNegative +TypeInvariant == + /\ phase ∈ PHASES + /\ condition ∈ CONDITIONS + /\ lastTransition ∈ TRANSITION_REASONS -Next == UNCHANGED <> +(* + Liveness: every non-blocked non-done state can eventually run to done. +*) +Liveness == []( (phase # "done" /\ phase # "blocked") => <> (phase = "done" /\ condition = "null") ) -Spec == Init /\ [][Next]_<> +Spec == + Init /\ [][Next]_<> /\ WF_<>(CompleteToDone) THEOREM Spec => []TypeInvariant -==== + +============================================================================== diff --git a/core/spec/task-structure.als b/core/spec/task-structure.als index 07d4985..e3128ff 100644 --- a/core/spec/task-structure.als +++ b/core/spec/task-structure.als @@ -1,30 +1,73 @@ module task_structure -// Draft Alloy skeleton for structure invariants of the orchestration tree. +// Formal Alloy model of task tree structure and cost constraints. sig Task { parent: lone Task, children: set Task, - terminal: lone Terminal, - failureSummary: lone FailureSummary + rootId: Task, + allocated: Int, + consumed: Int, + childAllocated: Int, + childRecovered: Int } -abstract sig Terminal {} -one sig Done, Failed, Blocked, Canceled extends Terminal {} +// Root tasks are those without a parent +fun roots: set Task { + { t: Task | no t.parent } +} + +fun remaining[t: Task]: Int { + t.allocated - t.consumed - t.childAllocated + t.childRecovered +} -sig FailureSummary {} +// No cycles in the parent pointer graph. +fact NoCycles { + no t: Task | t in t.^parent +} +// Parent-child consistency. fact ParentChildConsistency { all t: Task | all c: t.children | c.parent = t } -fact AcyclicParent { - no t: Task | t in t.^parent +// Parent-child consistency is explicit as an assertion too, for command checks. +assert parentChildConsistency { + all t: Task | all c: t.children | c.parent = t +} + +// Root identity is stable down the tree. +fact RootIdConsistency { + all t: Task | + (no t.parent and t.rootId = t) or (some t.parent and t.rootId = t.parent.rootId) +} + +assert rootIdConsistency { + all t: Task | + (no t.parent and t.rootId = t) or (some t.parent and t.rootId = t.parent.rootId) +} + +// Cost fields are non-negative and remaining budget is non-negative. +fact CostNonNegativity { + all t: Task | + t.allocated >= 0 and + t.consumed >= 0 and + t.childAllocated >= 0 and + t.childRecovered >= 0 and + remaining[t] >= 0 } -fact TerminalRequiresSummary { +assert costNonNegativity { all t: Task | - (t.terminal = Failed or t.terminal = Blocked) implies some t.failureSummary + t.allocated >= 0 and + t.consumed >= 0 and + t.childAllocated >= 0 and + t.childRecovered >= 0 and + remaining[t] >= 0 } -run {} +// Structural sanity checks. +check parentChildConsistency for 5 +check rootIdConsistency for 5 +check costNonNegativity for 5 +check NoCycles for 5 From ba7d8652a63ebfc8f6245ee047b8b741cca07cc3 Mon Sep 17 00:00:00 2001 From: krandder Date: Thu, 5 Mar 2026 09:15:20 -0300 Subject: [PATCH 2/5] Fix and verify formal specs and CI invocations --- .github/workflows/formal-verification.yml | 8 +- ...servation.cfg => TaskCostConservation.cfg} | 3 +- ...servation.tla => TaskCostConservation.tla} | 91 ++++++----- .../{task-lease.cfg => TaskLeaseProtocol.cfg} | 3 +- core/spec/TaskLeaseProtocol.tla | 151 ++++++++++++++++++ .../{task-lifecycle.cfg => TaskLifecycle.cfg} | 1 + .../{task-lifecycle.tla => TaskLifecycle.tla} | 31 +++- core/spec/task-lease.tla | 126 --------------- core/spec/task-structure.als | 10 +- 9 files changed, 247 insertions(+), 177 deletions(-) rename core/spec/{task-cost-conservation.cfg => TaskCostConservation.cfg} (84%) rename core/spec/{task-cost-conservation.tla => TaskCostConservation.tla} (60%) rename core/spec/{task-lease.cfg => TaskLeaseProtocol.cfg} (84%) create mode 100644 core/spec/TaskLeaseProtocol.tla rename core/spec/{task-lifecycle.cfg => TaskLifecycle.cfg} (88%) rename core/spec/{task-lifecycle.tla => TaskLifecycle.tla} (74%) delete mode 100644 core/spec/task-lease.tla diff --git a/.github/workflows/formal-verification.yml b/.github/workflows/formal-verification.yml index 8f26271..9db0bec 100644 --- a/.github/workflows/formal-verification.yml +++ b/.github/workflows/formal-verification.yml @@ -34,11 +34,11 @@ jobs: - name: Run TLA+ specifications shell: bash run: | - java -cp .ci-tools/tla2tools.jar tlc2.TLC -config core/spec/task-lease.cfg core/spec/task-lease.tla - java -cp .ci-tools/tla2tools.jar tlc2.TLC -config core/spec/task-lifecycle.cfg core/spec/task-lifecycle.tla - java -cp .ci-tools/tla2tools.jar tlc2.TLC -config core/spec/task-cost-conservation.cfg core/spec/task-cost-conservation.tla + java -cp .ci-tools/tla2tools.jar tlc2.TLC -config core/spec/TaskLeaseProtocol.cfg core/spec/TaskLeaseProtocol.tla + java -cp .ci-tools/tla2tools.jar tlc2.TLC -config core/spec/TaskLifecycle.cfg core/spec/TaskLifecycle.tla + java -cp .ci-tools/tla2tools.jar tlc2.TLC -config core/spec/TaskCostConservation.cfg core/spec/TaskCostConservation.tla - name: Run Alloy checks shell: bash run: | - java -jar .ci-tools/alloy.jar exec core/spec/task-structure.als --command "check*" --output - --type none + java -jar .ci-tools/alloy.jar exec --command "check*" --output - --type none core/spec/task-structure.als diff --git a/core/spec/task-cost-conservation.cfg b/core/spec/TaskCostConservation.cfg similarity index 84% rename from core/spec/task-cost-conservation.cfg rename to core/spec/TaskCostConservation.cfg index bc61cf5..83e9846 100644 --- a/core/spec/task-cost-conservation.cfg +++ b/core/spec/TaskCostConservation.cfg @@ -3,7 +3,8 @@ CONSTANT Tasks = {"root", "child-a", "child-b"} RootTask = "root" NoneTask = "none" - InitialAllocation = 100 + InitialAllocation = 12 + MaxAmount = 3 INVARIANTS TypeInvariant AliveNonNegative diff --git a/core/spec/task-cost-conservation.tla b/core/spec/TaskCostConservation.tla similarity index 60% rename from core/spec/task-cost-conservation.tla rename to core/spec/TaskCostConservation.tla index 977a455..d6a0931 100644 --- a/core/spec/task-cost-conservation.tla +++ b/core/spec/TaskCostConservation.tla @@ -18,7 +18,8 @@ CONSTANTS Tasks, RootTask, NoneTask, - InitialAllocation + InitialAllocation, + MaxAmount VARIABLES alive, @@ -28,12 +29,26 @@ VARIABLES childRecovered, parent -Min(x, y) == IF x < y THEN x ELSE y +Remaining(task) == + allocated[task] - consumed[task] - childAllocated[task] + childRecovered[task] -Remaining(task) == allocated[task] - consumed[task] - childAllocated[task] + childRecovered[task] +RECURSIVE ConsumedSum(_), RemainingSum(_) +ConsumedSum(tasks) == + IF tasks = {} + THEN 0 + ELSE LET t == CHOOSE x \in tasks: TRUE + IN consumed[t] + ConsumedSum(tasks \ {t}) + +RemainingSum(tasks) == + IF tasks = {} + THEN 0 + ELSE LET t == CHOOSE x \in tasks: TRUE + IN Remaining(t) + RemainingSum(tasks \ {t}) Init == - /\ RootTask ∈ Tasks + /\ RootTask \in Tasks + /\ InitialAllocation \in Nat + /\ MaxAmount \in 1..InitialAllocation /\ alive = {RootTask} /\ allocated = [t \in Tasks |-> IF t = RootTask THEN InitialAllocation ELSE 0] /\ consumed = [t \in Tasks |-> 0] @@ -42,18 +57,19 @@ Init == /\ parent = [t \in Tasks |-> NoneTask] TypeInvariant == - /\ RootTask ∈ Tasks - /\ parent ∈ [Tasks -> (Tasks ∪ {NoneTask})] - /\ allocated ∈ [Tasks -> Nat] - /\ consumed ∈ [Tasks -> Nat] - /\ childAllocated ∈ [Tasks -> Nat] - /\ childRecovered ∈ [Tasks -> Nat] - /\ alive ⊆ Tasks - /\ RootTask ∈ alive - /\ InitialAllocation >= 0 + /\ RootTask \in Tasks + /\ parent \in [Tasks -> (Tasks \cup {NoneTask})] + /\ allocated \in [Tasks -> Nat] + /\ consumed \in [Tasks -> Nat] + /\ childAllocated \in [Tasks -> Nat] + /\ childRecovered \in [Tasks -> Nat] + /\ alive \subseteq Tasks + /\ RootTask \in alive + /\ InitialAllocation \in Nat + /\ MaxAmount \in 1..InitialAllocation AliveNonNegative == - ∀ t ∈ alive: + \A t \in alive: /\ allocated[t] >= 0 /\ consumed[t] >= 0 /\ childAllocated[t] >= 0 @@ -61,24 +77,24 @@ AliveNonNegative == /\ Remaining(t) >= 0 ParentConsistency == - ∀ t ∈ alive: - \/ t = RootTask => parent[t] = NoneTask - \/ (parent[t] ∈ alive /\ parent[t] # t) + \A t \in alive: + IF t = RootTask + THEN parent[t] = NoneTask + ELSE /\ parent[t] \in alive + /\ parent[t] # t CostConservation == - LET totalConsumed == Sum({consumed[t] : t ∈ alive}) - totalRemaining == Sum({Remaining(t) : t ∈ alive}) - remainingRoot == allocated[RootTask] - IN - totalConsumed + totalRemaining = remainingRoot + LET totalConsumed == ConsumedSum(alive) + totalRemaining == RemainingSum(alive) + IN totalConsumed + totalRemaining = allocated[RootTask] CreateChild(parentTask, child, amount) == - /\ parentTask ∈ alive - /\ child ∈ Tasks - /\ child ∉ alive - /\ amount ∈ Nat \ {0} + /\ parentTask \in alive + /\ child \in Tasks + /\ child \notin alive + /\ amount \in 1..MaxAmount /\ amount <= Remaining(parentTask) - /\ alive' = alive ∪ {child} + /\ alive' = alive \cup {child} /\ allocated' = [allocated EXCEPT ![child] = amount] /\ consumed' = consumed /\ childAllocated' = [childAllocated EXCEPT ![parentTask] = childAllocated[parentTask] + amount] @@ -86,8 +102,8 @@ CreateChild(parentTask, child, amount) == /\ parent' = [parent EXCEPT ![child] = parentTask] ReportCost(task, amount) == - /\ task ∈ alive - /\ amount ∈ Nat \ {0} + /\ task \in alive + /\ amount \in 1..MaxAmount /\ amount <= Remaining(task) /\ alive' = alive /\ allocated' = allocated @@ -97,11 +113,10 @@ ReportCost(task, amount) == /\ parent' = parent RecoverFromChild(parentTask, child, amount) == - /\ parentTask ∈ alive + /\ parentTask \in alive + /\ child \in alive /\ parent[child] = parentTask - /\ child ∈ alive - /\ amount ∈ Nat - /\ amount > 0 + /\ amount \in 1..MaxAmount /\ LET recoverable == Remaining(child) recovered == IF amount <= recoverable THEN amount ELSE recoverable IN @@ -112,14 +127,12 @@ RecoverFromChild(parentTask, child, amount) == /\ childRecovered' = [childRecovered EXCEPT ![parentTask] = childRecovered[parentTask] + recovered] /\ parent' = parent -Next == - \/ ∃ p ∈ alive, c ∈ Tasks \ alive, a ∈ Nat: CreateChild(p, c, a) - \/ ∃ t ∈ alive, a ∈ Nat \ {0}: ReportCost(t, a) - \/ ∃ p ∈ alive, c ∈ alive, a ∈ Nat: RecoverFromChild(p, c, a) +Next == + \/ \E p \in alive, c \in Tasks \ alive, a \in 1..MaxAmount: CreateChild(p, c, a) + \/ \E t \in alive, a \in 1..MaxAmount: ReportCost(t, a) + \/ \E p \in alive, c \in alive, a \in 1..MaxAmount: RecoverFromChild(p, c, a) Spec == Init /\ [][Next]_<> -THEOREM Spec => [](TypeInvariant /\ AliveNonNegative /\ CostConservation) - ============================================================================== diff --git a/core/spec/task-lease.cfg b/core/spec/TaskLeaseProtocol.cfg similarity index 84% rename from core/spec/task-lease.cfg rename to core/spec/TaskLeaseProtocol.cfg index b4ed773..0abbfb8 100644 --- a/core/spec/task-lease.cfg +++ b/core/spec/TaskLeaseProtocol.cfg @@ -2,10 +2,11 @@ SPECIFICATION Spec CONSTANT Agents = {"agent-1", "agent-2", "agent-3"} NoneAgent = "none" - Task = {"task-1"} + MaxFence = 6 INVARIANTS TypeInvariant MutualExclusion NoStateChangeOnStaleAttempt + StaleEventRejected CHECK_DEADLOCK FALSE diff --git a/core/spec/TaskLeaseProtocol.tla b/core/spec/TaskLeaseProtocol.tla new file mode 100644 index 0000000..5de31ac --- /dev/null +++ b/core/spec/TaskLeaseProtocol.tla @@ -0,0 +1,151 @@ +------------------------------ MODULE TaskLeaseProtocol ------------------------------ +EXTENDS Naturals, FiniteSets + +(*************************************************************************** + Fence-based single-task lease protocol with competing agents. + + This model checks: + - Mutual exclusion: at most one agent can be active. + - Stale events are rejected and do not mutate lease/task state. +***************************************************************************) + +CONSTANTS + Agents, + NoneAgent, + MaxFence + +TASK_STATES == {"free", "leased", "active"} +ACTION_TYPES == {"lease", "work_accepted", "work_stale", "release", "none"} + +VARIABLES + fence, + holder, + taskState, + active, + lastAction, + lastActionAccepted, + attemptAgent, + attemptToken, + prevFence, + prevHolder, + prevTaskState, + prevActive + +vars == + << fence, holder, taskState, active, lastAction, lastActionAccepted, + attemptAgent, attemptToken, prevFence, prevHolder, prevTaskState, prevActive >> + +Init == + /\ fence = 0 + /\ holder = NoneAgent + /\ taskState = "free" + /\ active = [a \in Agents |-> FALSE] + /\ lastAction = "none" + /\ lastActionAccepted = TRUE + /\ attemptAgent = NoneAgent + /\ attemptToken = 0 + /\ prevFence = fence + /\ prevHolder = holder + /\ prevTaskState = taskState + /\ prevActive = active + +TypeInvariant == + /\ MaxFence \in Nat + /\ fence \in Nat + /\ fence <= MaxFence + /\ holder \in Agents \cup {NoneAgent} + /\ taskState \in TASK_STATES + /\ active \in [Agents -> BOOLEAN] + /\ lastAction \in ACTION_TYPES + /\ lastActionAccepted \in BOOLEAN + /\ attemptAgent \in Agents \cup {NoneAgent} + /\ attemptToken \in 0..MaxFence + /\ prevFence \in Nat + /\ prevHolder \in Agents \cup {NoneAgent} + /\ prevTaskState \in TASK_STATES + /\ prevActive \in [Agents -> BOOLEAN] + +Snapshot == + /\ prevFence' = fence + /\ prevHolder' = holder + /\ prevTaskState' = taskState + /\ prevActive' = active + +NoStateChangeOnStaleAttempt == + lastAction = "work_stale" => + /\ fence = prevFence + /\ holder = prevHolder + /\ taskState = prevTaskState + /\ active = prevActive + +StaleEventRejected == + lastAction = "work_stale" => ~lastActionAccepted + +GrantLease(agent) == + /\ agent \in Agents + /\ taskState = "free" + /\ holder = NoneAgent + /\ fence < MaxFence + /\ \A a \in Agents: ~active[a] + /\ Snapshot + /\ fence' = fence + 1 + /\ holder' = agent + /\ taskState' = "leased" + /\ active' = active + /\ lastAction' = "lease" + /\ lastActionAccepted' = TRUE + /\ attemptAgent' = NoneAgent + /\ attemptToken' = 0 + +WorkAttempt(agent, token) == + /\ agent \in Agents + /\ token \in 0..MaxFence + /\ Snapshot + /\ attemptAgent' = agent + /\ attemptToken' = token + /\ IF /\ token = fence + /\ holder = agent + /\ taskState = "leased" + /\ ~active[agent] + /\ \A other \in Agents \ {agent}: ~active[other] + THEN /\ fence' = fence + /\ holder' = holder + /\ taskState' = "active" + /\ active' = [active EXCEPT ![agent] = TRUE] + /\ lastAction' = "work_accepted" + /\ lastActionAccepted' = TRUE + ELSE /\ fence' = fence + /\ holder' = holder + /\ taskState' = taskState + /\ active' = active + /\ lastAction' = "work_stale" + /\ lastActionAccepted' = FALSE + +Release(agent) == + /\ agent \in Agents + /\ taskState = "active" + /\ holder = agent + /\ active[agent] + /\ fence < MaxFence + /\ Snapshot + /\ fence' = fence + 1 + /\ holder' = NoneAgent + /\ taskState' = "free" + /\ active' = [active EXCEPT ![agent] = FALSE] + /\ lastAction' = "release" + /\ lastActionAccepted' = TRUE + /\ attemptAgent' = NoneAgent + /\ attemptToken' = 0 + +Next == + \/ \E agent \in Agents: GrantLease(agent) + \/ \E agent \in Agents: Release(agent) + \/ \E agent \in Agents, token \in 0..MaxFence: WorkAttempt(agent, token) + +MutualExclusion == + Cardinality({a \in Agents: active[a]}) <= 1 + +Spec == + Init /\ [][Next]_vars + +============================================================================== diff --git a/core/spec/task-lifecycle.cfg b/core/spec/TaskLifecycle.cfg similarity index 88% rename from core/spec/task-lifecycle.cfg rename to core/spec/TaskLifecycle.cfg index eee33f2..8b6ffc0 100644 --- a/core/spec/task-lifecycle.cfg +++ b/core/spec/TaskLifecycle.cfg @@ -4,6 +4,7 @@ CONSTANT InitCondition = "active" INVARIANTS TypeInvariant + TransitionSafety PROPERTY Liveness CHECK_DEADLOCK diff --git a/core/spec/task-lifecycle.tla b/core/spec/TaskLifecycle.tla similarity index 74% rename from core/spec/task-lifecycle.tla rename to core/spec/TaskLifecycle.tla index 56dc9f1..23d700f 100644 --- a/core/spec/task-lifecycle.tla +++ b/core/spec/TaskLifecycle.tla @@ -4,7 +4,7 @@ EXTENDS Naturals, FiniteSets (*************************************************************************** Phase-transition state machine for task execution. - This module models the 11 legal transitions from the production transition + This module models the 12 legal transitions from the production transition table and verifies a combined safety/liveness property: - Safety: transitions are restricted to legal source/target pairs. - Liveness: any non-blocked, non-done state can eventually reach done. @@ -35,11 +35,13 @@ LEGAL_TRANSITIONS == { TRANSITION_REASONS == { t[5] : t ∈ LEGAL_TRANSITIONS } ∪ {"done", "init"} -VARIABLES phase, condition, lastTransition +VARIABLES phase, condition, prevPhase, prevCondition, lastTransition Init == /\ phase = InitPhase /\ condition = InitCondition + /\ prevPhase = phase + /\ prevCondition = condition /\ lastTransition = "init" ApplyLegalTransition == @@ -52,6 +54,8 @@ ApplyLegalTransition == IN /\ phase = fromPhase /\ condition = fromCondition + /\ prevPhase' = phase + /\ prevCondition' = condition /\ phase' = toPhase /\ condition' = toCondition /\ lastTransition' = reason @@ -64,6 +68,8 @@ CompleteToDone == /\ phase # "done" /\ phase # "blocked" /\ condition # "null" + /\ prevPhase' = phase + /\ prevCondition' = condition /\ phase' = "done" /\ condition' = "null" /\ lastTransition' = "done" @@ -73,15 +79,34 @@ Next == ApplyLegalTransition \/ CompleteToDone TypeInvariant == /\ phase ∈ PHASES /\ condition ∈ CONDITIONS + /\ prevPhase ∈ PHASES + /\ prevCondition ∈ CONDITIONS /\ lastTransition ∈ TRANSITION_REASONS +TransitionSafety == + \/ lastTransition = "init" + \/ /\ lastTransition = "done" + /\ prevPhase # "done" + /\ prevPhase # "blocked" + /\ prevCondition # "null" + /\ phase = "done" + /\ condition = "null" + \/ \E transition ∈ LEGAL_TRANSITIONS: + /\ transition[1] = prevPhase + /\ transition[2] = prevCondition + /\ transition[3] = phase + /\ transition[4] = condition + /\ transition[5] = lastTransition + (* Liveness: every non-blocked non-done state can eventually run to done. *) Liveness == []( (phase # "done" /\ phase # "blocked") => <> (phase = "done" /\ condition = "null") ) Spec == - Init /\ [][Next]_<> /\ WF_<>(CompleteToDone) + Init + /\ [][Next]_<> + /\ WF_<>(CompleteToDone) THEOREM Spec => []TypeInvariant diff --git a/core/spec/task-lease.tla b/core/spec/task-lease.tla deleted file mode 100644 index f7f2ec1..0000000 --- a/core/spec/task-lease.tla +++ /dev/null @@ -1,126 +0,0 @@ ------------------------------- MODULE TaskLeaseProtocol ------------------------------ -EXTENDS Naturals, FiniteSets - -(*************************************************************************** - Fence-based lease protocol with competing agents. - - This model checks: - - Mutual exclusion: no task has more than one active agent. - - Stale fence events are always rejected and do not change state. -***************************************************************************) - -CONSTANTS - Agents, - NoneAgent, - Task - -TASK_STATES == {"free", "leased", "active"} -ACTION_TYPES == {"lease", "work_accepted", "work_stale", "release", "none"} - -VARIABLES - fence, - holder, - taskState, - activeTask, - lastAction, - lastActionAccepted, - prevFence, - prevHolder, - prevTaskState, - prevActiveTask - -Init == - /\ fence = [t \in Task |-> 0] - /\ holder = [t \in Task |-> NoneAgent] - /\ taskState = [t \in Task |-> "free"] - /\ activeTask = [a \in Agents |-> NoneAgent] - /\ lastAction = "none" - /\ lastActionAccepted = TRUE - /\ prevFence = fence - /\ prevHolder = holder - /\ prevTaskState = taskState - /\ prevActiveTask = activeTask - -TypeInvariant == - /\ fence ∈ [Task -> Nat] - /\ holder ∈ [Task -> Agents ∪ {NoneAgent}] - /\ taskState ∈ [Task -> TASK_STATES] - /\ activeTask ∈ [Agents -> (Task ∪ {NoneAgent})] - /\ lastAction ∈ ACTION_TYPES - /\ lastActionAccepted ∈ BOOLEAN - /\ prevFence ∈ [Task -> Nat] - /\ prevHolder ∈ [Task -> Agents ∪ {NoneAgent}] - /\ prevTaskState ∈ [Task -> TASK_STATES] - /\ prevActiveTask ∈ [Agents -> (Task ∪ {NoneAgent})] - -Snapshot == - /\ prevFence' = fence - /\ prevHolder' = holder - /\ prevTaskState' = taskState - /\ prevActiveTask' = activeTask - -NoStateChangeOnStaleAttempt == - /\ lastAction = "work_stale" => - /\ fence = prevFence - /\ holder = prevHolder - /\ taskState = prevTaskState - /\ activeTask = prevActiveTask - -GrantLease(agent) == - /\ agent ∈ Agents - /\ ∀ a ∈ Agents: activeTask[a] = NoneAgent - /\ Snapshot - /\ lastAction' = "lease" - /\ lastActionAccepted' = TRUE - /\ fence' = [fence EXCEPT ![Task] = fence[Task] + 1] - /\ holder' = [holder EXCEPT ![Task] = agent] - /\ taskState' = [taskState EXCEPT ![Task] = "leased"] - /\ activeTask' = [activeTask EXCEPT ![agent] = NoneAgent] - -WorkAttempt(agent, token) == - /\ agent ∈ Agents - /\ Snapshot - /\ IF /\ token = fence[Task] - /\ holder[Task] = agent - /\ taskState[Task] = "leased" - /\ activeTask[agent] = NoneAgent - /\ ∀ other ∈ Agents \ {agent}: activeTask[other] = NoneAgent - THEN /\ lastAction' = "work_accepted" - /\ lastActionAccepted' = TRUE - /\ fence' = fence - /\ holder' = holder - /\ taskState' = [taskState EXCEPT ![Task] = "active"] - /\ activeTask' = [activeTask EXCEPT ![agent] = Task] - ELSE /\ lastAction' = "work_stale" - /\ lastActionAccepted' = FALSE - /\ fence' = fence - /\ holder' = holder - /\ taskState' = taskState - /\ activeTask' = activeTask - -Release(agent) == - /\ agent ∈ Agents - /\ activeTask[agent] = Task - /\ holder[Task] = agent - /\ taskState[Task] = "active" - /\ Snapshot - /\ lastAction' = "release" - /\ lastActionAccepted' = TRUE - /\ fence' = [fence EXCEPT ![Task] = fence[Task] + 1] - /\ holder' = [holder EXCEPT ![Task] = NoneAgent] - /\ taskState' = [taskState EXCEPT ![Task] = "free"] - /\ activeTask' = [activeTask EXCEPT ![agent] = NoneAgent] - -Next == - \\/ ∃ agent ∈ Agents: GrantLease(agent) - \\/ ∃ agent ∈ Agents: Release(agent) - \\/ ∃ agent ∈ Agents, token ∈ Nat: WorkAttempt(agent, token) - -MutualExclusion == - ∀ t ∈ Task: Cardinality({a ∈ Agents: activeTask[a] = t}) <= 1 - -Spec == - Init /\ [][Next]_<> - -============================================================================== diff --git a/core/spec/task-structure.als b/core/spec/task-structure.als index e3128ff..26a4d5f 100644 --- a/core/spec/task-structure.als +++ b/core/spec/task-structure.als @@ -28,12 +28,12 @@ fact NoCycles { // Parent-child consistency. fact ParentChildConsistency { - all t: Task | all c: t.children | c.parent = t + all t: Task | all c: Task | (c in t.children) iff (c.parent = t) } // Parent-child consistency is explicit as an assertion too, for command checks. assert parentChildConsistency { - all t: Task | all c: t.children | c.parent = t + all t: Task | all c: Task | (c in t.children) iff (c.parent = t) } // Root identity is stable down the tree. @@ -66,8 +66,12 @@ assert costNonNegativity { remaining[t] >= 0 } +assert noCycles { + no t: Task | t in t.^parent +} + // Structural sanity checks. check parentChildConsistency for 5 check rootIdConsistency for 5 check costNonNegativity for 5 -check NoCycles for 5 +check noCycles for 5 From ca12668043c77fdd92b1e4c4529ac483793acc3e Mon Sep 17 00:00:00 2001 From: krandder Date: Thu, 5 Mar 2026 17:13:29 -0300 Subject: [PATCH 3/5] Add task CLI and daemon support --- core/cli/task.ts | 1707 ++++++++++++++++++++++++++++++++++++ core/clock.ts | 6 +- core/docs/task-cli-spec.md | 868 ++++++++++++++++++ core/reducer.ts | 35 + core/types.ts | 28 +- core/validator.ts | 46 +- middle/http.ts | 575 +++++++++++- middle/test/http.test.ts | 119 ++- package.json | 6 +- 9 files changed, 3381 insertions(+), 9 deletions(-) create mode 100644 core/cli/task.ts create mode 100644 core/docs/task-cli-spec.md diff --git a/core/cli/task.ts b/core/cli/task.ts new file mode 100644 index 0000000..728c759 --- /dev/null +++ b/core/cli/task.ts @@ -0,0 +1,1707 @@ +#!/usr/bin/env node +import * as fs from "node:fs"; +import * as http from "node:http"; +import * as os from "node:os"; +import * as path from "node:path"; + +const PORT = Number.parseInt(process.env["ORCHESTRATOR_PORT"] ?? "18800", 10); +const BASE_URL = `http://127.0.0.1:${Number.isFinite(PORT) ? PORT : 18800}`; + +interface ApiResponse { + status: number; + body: T; +} + +interface TaskContext { + taskId: string; + phase: string | null; + fenceToken: number; + sessionId: string; + journalPath: string; + codeWorktree: string | null; + claimedAt: number; + reviewNotes?: string[]; +} + +type FlagValue = boolean | string | string[]; + +interface ParsedFlags { + positionals: string[]; + flags: Record; +} + +class CliError extends Error { + code: number; + + constructor(message: string, code = 1) { + super(message); + this.code = code; + } +} + +class ApiError extends Error { + status: number; + body: unknown; + + constructor(status: number, body: unknown) { + const bodyObj = asRecord(body); + const msg = bodyObj?.["message"]; + const fallback = typeof msg === "string" ? msg : `API error (${status})`; + super(fallback); + this.status = status; + this.body = body; + } +} + +function asRecord(value: unknown): Record | null { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + return null; +} + +function asString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function asNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function asArray(value: unknown): T[] { + return Array.isArray(value) ? (value as T[]) : []; +} + +function formatMoney(value: unknown): string { + const n = asNumber(value); + if (n === null) return "n/a"; + return `$${n.toFixed(2)}`; +} + +function formatIso(ts: unknown): string { + const n = asNumber(ts); + if (n === null) return "n/a"; + return new Date(n).toISOString().replace("T", " ").replace(".000Z", " UTC"); +} + +function normalizeTaskId(raw: string): string { + const trimmed = raw.trim(); + const match = trimmed.match(/^T(\d+)$/i); + if (match) return match[1]!; + return trimmed; +} + +function parseFlags(argv: string[]): ParsedFlags { + const positionals: string[] = []; + const flags: Record = {}; + + for (let i = 0; i < argv.length; i++) { + const token = argv[i]!; + if (!token.startsWith("--")) { + positionals.push(token); + continue; + } + + if (token === "--") { + for (let j = i + 1; j < argv.length; j++) positionals.push(argv[j]!); + break; + } + + const eq = token.indexOf("="); + let key = token.slice(2); + let value: FlagValue = true; + + if (eq >= 0) { + key = token.slice(2, eq); + value = token.slice(eq + 1); + } else { + const next = argv[i + 1]; + if (next && !next.startsWith("--")) { + value = next; + i += 1; + } + } + + const existing = flags[key]; + if (existing === undefined) { + flags[key] = value; + } else if (Array.isArray(existing)) { + existing.push(String(value)); + flags[key] = existing; + } else { + flags[key] = [String(existing), String(value)]; + } + } + + return { positionals, flags }; +} + +function getFlagString(flags: Record, key: string): string | undefined { + const value = flags[key]; + if (value === undefined || value === false) return undefined; + if (Array.isArray(value)) return value[value.length - 1]; + if (value === true) return undefined; + return value; +} + +function getFlagBool(flags: Record, key: string): boolean { + const value = flags[key]; + if (value === undefined) return false; + if (value === true) return true; + if (value === false) return false; + if (Array.isArray(value)) { + const last = value[value.length - 1]?.toLowerCase(); + return last === "1" || last === "true" || last === "yes" || last === "on"; + } + const normalized = value.toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; +} + +function getFlagList(flags: Record, key: string): string[] { + const value = flags[key]; + if (value === undefined || value === false || value === true) return []; + const raw = Array.isArray(value) ? value : [value]; + const out: string[] = []; + for (const entry of raw) { + for (const part of entry.split(",")) { + const trimmed = part.trim(); + if (trimmed) out.push(trimmed); + } + } + return out; +} + +function parseDurationMs(raw: string | undefined, fallbackMs: number): number { + if (!raw) return fallbackMs; + const text = raw.trim().toLowerCase(); + const match = text.match(/^(\d+)(ms|s|m|h)?$/); + if (!match) { + throw new CliError(`Invalid duration: ${raw}. Use formats like 10m, 30m, 1h.`, 1); + } + const amount = Number.parseInt(match[1]!, 10); + const unit = match[2] ?? "m"; + const multiplier = unit === "h" ? 3_600_000 + : unit === "m" ? 60_000 + : unit === "s" ? 1_000 + : 1; + return amount * multiplier; +} + +function findTaskFile(startDir: string): string | null { + let current = path.resolve(startDir); + + while (true) { + const candidate = path.join(current, ".task"); + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + return candidate; + } + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + return null; +} + +function readTaskContext(): { filePath: string | null; context: TaskContext | null } { + const taskFile = findTaskFile(process.cwd()); + if (!taskFile) return { filePath: null, context: null }; + + try { + const parsed = JSON.parse(fs.readFileSync(taskFile, "utf-8")) as unknown; + const obj = asRecord(parsed); + if (!obj) return { filePath: taskFile, context: null }; + + const taskId = asString(obj["taskId"]); + const phase = asString(obj["phase"]); + const fenceToken = asNumber(obj["fenceToken"]); + const sessionId = asString(obj["sessionId"]); + const journalPath = asString(obj["journalPath"]); + const codeWorktree = asString(obj["codeWorktree"]); + const claimedAt = asNumber(obj["claimedAt"]); + + if (!taskId || fenceToken === null || !sessionId || !journalPath || claimedAt === null) { + return { filePath: taskFile, context: null }; + } + + return { + filePath: taskFile, + context: { + taskId, + phase, + fenceToken, + sessionId, + journalPath, + codeWorktree, + claimedAt, + reviewNotes: asArray(obj["reviewNotes"]), + }, + }; + } catch { + return { filePath: taskFile, context: null }; + } +} + +function writeTaskContext(taskContext: TaskContext, roots: string[]): void { + const content = JSON.stringify(taskContext, null, 2) + "\n"; + for (const root of roots) { + if (!root) continue; + fs.mkdirSync(root, { recursive: true }); + fs.writeFileSync(path.join(root, ".task"), content, "utf-8"); + } +} + +function clearTaskContextFile(): void { + const { filePath } = readTaskContext(); + if (filePath && fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } +} + +function requireAgentId(): string { + const agentId = process.env["TASKCORE_AGENT_ID"]?.trim(); + if (!agentId) { + throw new CliError("TASKCORE_AGENT_ID is required for this command.", 3); + } + return agentId; +} + +function currentTaskId(explicit?: string): string { + if (explicit) return normalizeTaskId(explicit); + + const { context } = readTaskContext(); + if (context?.taskId) return normalizeTaskId(context.taskId); + + const envTaskId = process.env["TASK_ID"]?.trim(); + if (envTaskId) return normalizeTaskId(envTaskId); + + throw new CliError("No active task context. Claim a task first or pass a task id.", 1); +} + +async function httpRequest(method: "GET" | "POST" | "PATCH", urlPath: string, body?: unknown): Promise { + return await new Promise((resolve, reject) => { + const url = new URL(urlPath, BASE_URL); + const payload = body === undefined ? undefined : JSON.stringify(body); + + const req = http.request( + { + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + method, + headers: payload + ? { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload), + } + : {}, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf-8"); + if (!raw.trim()) { + resolve({ status: res.statusCode ?? 500, body: {} }); + return; + } + + try { + resolve({ status: res.statusCode ?? 500, body: JSON.parse(raw) }); + } catch { + resolve({ status: res.statusCode ?? 500, body: raw }); + } + }); + }, + ); + + req.on("error", reject); + if (payload) req.write(payload); + req.end(); + }); +} + +async function apiRequest(method: "GET" | "POST" | "PATCH", pathName: string, body?: unknown): Promise> { + let response: ApiResponse; + try { + response = await httpRequest(method, pathName, body); + } catch (err) { + throw new CliError(`Unable to reach daemon at ${BASE_URL}: ${String(err)}`, 2); + } + + const responseObj = asRecord(response.body); + if (response.status < 200 || response.status >= 300) { + throw new ApiError(response.status, responseObj ?? response.body); + } + + return responseObj ?? {}; +} + +function printTable(rows: string[][]): void { + if (rows.length === 0) return; + + const widths: number[] = []; + for (const row of rows) { + row.forEach((cell, idx) => { + widths[idx] = Math.max(widths[idx] ?? 0, cell.length); + }); + } + + for (const row of rows) { + const line = row + .map((cell, idx) => cell.padEnd(widths[idx] ?? cell.length)) + .join(" "); + process.stdout.write(line + "\n"); + } +} + +function priorityRank(priority: string): number { + switch (priority) { + case "critical": return 0; + case "high": return 1; + case "medium": return 2; + case "low": return 3; + case "backlog": return 4; + default: return 5; + } +} + +function toTaskListEntry(value: unknown): Record | null { + const t = asRecord(value); + if (!t) return null; + if (asString(t["id"]) === null) return null; + return t; +} + +function getString(obj: Record, key: string, fallback = ""): string { + const value = obj[key]; + return typeof value === "string" ? value : fallback; +} + +function getNumber(obj: Record, key: string, fallback = 0): number { + const value = obj[key]; + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function printHelp(): void { + const text = [ + "task CLI", + "", + "Usage:", + " task list [filters]", + " task show [--events] [--children] [--deps]", + " task events [--last N]", + " task attention [--format telegram]", + " task create --description <desc> [options]", + " task claim <id>", + " task release [--reason <reason>] [--worked]", + " task extend [--duration 15m|30m|1h]", + " task submit <evidence>", + " task complete <evidence>", + " task block <reason>", + " task cost <amount>", + " task update <message>", + " task analyze", + " task decide <execute|decompose>", + " task decompose <start|add|commit|cancel> ...", + " task review <read|note|approve|reject|request-changes> ...", + " task journal <read|write|write-file> ...", + " task worktree", + " task revive <id> [--reason <reason>]", + " task cancel <id> [--reason <reason>]", + " task budget <id> [--cost N] [--attempts phase:max,...]", + " task metadata <id> <key> <value>", + " task reparent <id> --parent <parent-id>", + " task incident <summary> --severity <...> --category <...>", + "", + "Global:", + " --json Print raw JSON response when available", + ].join("\n"); + process.stdout.write(text + "\n"); +} + +function ensureText(input: string | undefined, name: string): string { + if (!input || !input.trim()) { + throw new CliError(`${name} is required.`, 1); + } + return input.trim(); +} + +async function getTask(taskId: string): Promise<Record<string, unknown>> { + const body = await apiRequest("GET", `/tasks/${normalizeTaskId(taskId)}`); + const task = asRecord(body["task"]); + if (!task) throw new CliError(`Malformed task response for ${taskId}.`, 2); + return task; +} + +async function getTaskEvents(taskId: string): Promise<Record<string, unknown>[]> { + const body = await apiRequest("GET", `/tasks/${normalizeTaskId(taskId)}/events`); + return asArray<unknown>(body["events"]) + .map((event) => asRecord(event)) + .filter((event): event is Record<string, unknown> => event !== null); +} + +function printTaskOverview(task: Record<string, unknown>, includeDeps: boolean, childRows: Array<Record<string, unknown>>): void { + const id = getString(task, "id"); + const title = getString(task, "title", "(untitled)"); + const phase = getString(task, "phase", "terminal"); + const condition = getString(task, "condition", getString(task, "terminal", "unknown")); + const priority = getString(asRecord(task["metadata"]) ?? {}, "priority", "medium"); + const assignee = getString(asRecord(task["metadata"]) ?? {}, "assignee", "-"); + const reviewer = getString(asRecord(task["metadata"]) ?? {}, "reviewer", "-"); + + process.stdout.write(`--- T${id}: ${title} ---\n`); + process.stdout.write(`Priority: ${priority}\n`); + process.stdout.write(`Phase: ${phase}\n`); + process.stdout.write(`Condition: ${condition}\n`); + process.stdout.write(`Assignee: ${assignee || "-"}\n`); + process.stdout.write(`Reviewer: ${reviewer || "-"}\n`); + + const parentId = asString(task["parentId"]); + if (parentId) { + process.stdout.write(`Parent: T${parentId}\n`); + } + + process.stdout.write(`\nCreated: ${formatIso(task["createdAt"])}\n`); + process.stdout.write(`Updated: ${formatIso(task["updatedAt"])}\n`); + + process.stdout.write("\n## Description\n"); + process.stdout.write(getString(task, "description", "(none)") + "\n"); + + const attemptsObj = asRecord(task["attempts"]); + if (attemptsObj) { + process.stdout.write("\n## Attempts\n"); + for (const phaseKey of ["analysis", "decomposition", "execution", "review"]) { + const phaseAttempts = asRecord(attemptsObj[phaseKey]); + if (!phaseAttempts) continue; + const used = getNumber(phaseAttempts, "used", 0); + const max = getNumber(phaseAttempts, "max", 0); + process.stdout.write(` ${phaseKey.padEnd(12)} ${used}/${max} used\n`); + } + } + + const costObj = asRecord(task["cost"]); + if (costObj) { + const allocated = asNumber(costObj["allocated"]); + const consumed = asNumber(costObj["consumed"]); + const childAllocated = asNumber(costObj["childAllocated"]); + const childRecovered = asNumber(costObj["childRecovered"]); + const remaining = (allocated ?? 0) - (consumed ?? 0) - (childAllocated ?? 0) + (childRecovered ?? 0); + + process.stdout.write("\n## Cost\n"); + process.stdout.write(` Allocated: ${formatMoney(allocated)}\n`); + process.stdout.write(` Consumed: ${formatMoney(consumed)}\n`); + process.stdout.write(` Remaining: ${formatMoney(remaining)}\n`); + } + + const failures = asArray<unknown>(task["failureSummaries"]) + .map((entry) => asRecord(entry)) + .filter((entry): entry is Record<string, unknown> => entry !== null); + if (failures.length > 0) { + process.stdout.write("\n## Previous Failures\n"); + failures.forEach((failure, idx) => { + const whatFailed = getString(failure, "whatFailed", "(unknown)"); + const learned = getString(failure, "whatWasLearned", ""); + process.stdout.write(` - Attempt ${idx + 1}: ${whatFailed}\n`); + if (learned) process.stdout.write(` Learned: ${learned}\n`); + }); + } + + const reviewState = asRecord(task["reviewState"]); + const verdicts = asArray<unknown>(reviewState?.["verdicts"]) + .map((entry) => asRecord(entry)) + .filter((entry): entry is Record<string, unknown> => entry !== null); + if (verdicts.length > 0) { + process.stdout.write("\n## Review Feedback\n"); + for (const verdict of verdicts) { + const round = getNumber(verdict, "round", 0); + const reviewerId = getString(verdict, "reviewer", "reviewer"); + const value = getString(verdict, "verdict", "unknown"); + const reasoning = getString(verdict, "reasoning", ""); + process.stdout.write(` Round ${round} (${reviewerId}): ${value}\n`); + if (reasoning) process.stdout.write(` \"${reasoning}\"\n`); + } + } + + if (includeDeps) { + const deps = asArray<unknown>(task["dependencies"]) + .map((entry) => asRecord(entry)) + .filter((entry): entry is Record<string, unknown> => entry !== null); + process.stdout.write("\n## Dependencies\n"); + if (deps.length === 0) { + process.stdout.write(" (none)\n"); + } else { + for (const dep of deps) { + const target = getString(dep, "target", "?"); + const status = getString(dep, "status", "unknown"); + process.stdout.write(` T${target} (${status})\n`); + } + } + } + + process.stdout.write("\n## Children\n"); + if (childRows.length === 0) { + process.stdout.write(" (none)\n"); + } else { + for (const child of childRows) { + const cid = getString(child, "id"); + const ctitle = getString(child, "title", "(untitled)"); + const cphase = getString(child, "phase", "terminal"); + const ccondition = getString(child, "condition", getString(child, "terminal", "unknown")); + process.stdout.write(` T${cid} ${ctitle} [${cphase}.${ccondition}]\n`); + } + } +} + +async function cmdList(argv: string[], jsonMode: boolean): Promise<void> { + const { flags } = parseFlags(argv); + const body = await apiRequest("GET", "/tasks?full=true"); + const tasks = asArray<unknown>(body["tasks"]) + .map((task) => toTaskListEntry(task)) + .filter((task): task is Record<string, unknown> => task !== null); + + const phaseFilter = getFlagString(flags, "phase"); + const conditionFilter = getFlagString(flags, "condition"); + const terminalFilter = getFlagString(flags, "terminal"); + const assigneeFilter = getFlagString(flags, "assignee"); + const priorityFilter = getFlagString(flags, "priority"); + const parentFilter = getFlagString(flags, "parent"); + const mine = getFlagBool(flags, "mine"); + const limitRaw = getFlagString(flags, "limit"); + const limit = limitRaw ? Number.parseInt(limitRaw, 10) : null; + + const myAgent = process.env["TASKCORE_AGENT_ID"]?.trim(); + + const filtered = tasks + .filter((task) => { + const metadata = asRecord(task["metadata"]) ?? {}; + if (phaseFilter && getString(task, "phase") !== phaseFilter) return false; + if (conditionFilter && getString(task, "condition") !== conditionFilter) return false; + if (terminalFilter && getString(task, "terminal") !== terminalFilter) return false; + if (!terminalFilter && getString(task, "terminal")) return false; + if (assigneeFilter && getString(metadata, "assignee") !== assigneeFilter) return false; + if (priorityFilter && getString(metadata, "priority", "medium") !== priorityFilter) return false; + if (parentFilter && getString(task, "parentId") !== normalizeTaskId(parentFilter)) return false; + if (mine && myAgent && getString(metadata, "assignee") !== myAgent) return false; + return true; + }) + .sort((a, b) => { + const pa = priorityRank(getString(asRecord(a["metadata"]) ?? {}, "priority", "medium")); + const pb = priorityRank(getString(asRecord(b["metadata"]) ?? {}, "priority", "medium")); + if (pa !== pb) return pa - pb; + return getNumber(b, "updatedAt") - getNumber(a, "updatedAt"); + }); + + const shown = Number.isInteger(limit) && (limit ?? 0) > 0 + ? filtered.slice(0, limit ?? 0) + : filtered; + + if (jsonMode) { + process.stdout.write(JSON.stringify({ total: filtered.length, shown: shown.length, tasks: shown }, null, 2) + "\n"); + return; + } + + const rows: string[][] = [["ID", "Priority", "Phase", "Condition", "Assignee", "Title"]]; + for (const task of shown) { + const metadata = asRecord(task["metadata"]) ?? {}; + rows.push([ + `T${getString(task, "id")}`, + getString(metadata, "priority", "medium"), + getString(task, "phase", "terminal"), + getString(task, "condition", getString(task, "terminal", "unknown")), + getString(metadata, "assignee", "-"), + getString(task, "title", "(untitled)"), + ]); + } + + printTable(rows); + process.stdout.write(`\nShowing ${shown.length} of ${filtered.length} tasks\n`); + process.stdout.write("Hint: run `task show <id>` for full details.\n"); +} + +async function cmdShow(argv: string[], jsonMode: boolean): Promise<void> { + const { positionals, flags } = parseFlags(argv); + const taskId = normalizeTaskId(ensureText(positionals[0], "task id")); + const task = await getTask(taskId); + + const includeEvents = getFlagBool(flags, "events"); + const includeChildren = getFlagBool(flags, "children"); + const includeDeps = getFlagBool(flags, "deps"); + + const children: Record<string, unknown>[] = []; + if (includeChildren) { + const childrenBody = await apiRequest("GET", `/tasks?parentId=${encodeURIComponent(taskId)}&full=true`); + for (const entry of asArray<unknown>(childrenBody["tasks"])) { + const child = asRecord(entry); + if (child) children.push(child); + } + } + + const events = includeEvents ? await getTaskEvents(taskId) : []; + + if (jsonMode) { + process.stdout.write(JSON.stringify({ task, children, events }, null, 2) + "\n"); + return; + } + + printTaskOverview(task, includeDeps, children); + + if (includeEvents) { + process.stdout.write("\n## Events\n"); + for (const event of events) { + const ts = getNumber(event, "ts"); + const type = getString(event, "type", "unknown"); + process.stdout.write(` ${formatIso(ts)} ${type}\n`); + } + } +} + +async function cmdEvents(argv: string[], jsonMode: boolean): Promise<void> { + const { positionals, flags } = parseFlags(argv); + const taskId = normalizeTaskId(ensureText(positionals[0], "task id")); + const events = await getTaskEvents(taskId); + + const lastRaw = getFlagString(flags, "last"); + const lastN = lastRaw ? Number.parseInt(lastRaw, 10) : null; + const shown = Number.isInteger(lastN) && (lastN ?? 0) > 0 + ? events.slice(Math.max(0, events.length - (lastN ?? 0))) + : events; + + if (jsonMode) { + process.stdout.write(JSON.stringify({ taskId, events: shown }, null, 2) + "\n"); + return; + } + + for (const event of shown) { + const ts = formatIso(event["ts"]); + const type = getString(event, "type", "unknown"); + process.stdout.write(`[${ts}] ${type}\n`); + } +} + +async function cmdAttention(argv: string[], jsonMode: boolean): Promise<void> { + const { flags } = parseFlags(argv); + const format = getFlagString(flags, "format"); + + if (format === "telegram") { + const body = await apiRequest("GET", "/attention/telegram"); + if (jsonMode) { + process.stdout.write(JSON.stringify(body, null, 2) + "\n"); + return; + } + process.stdout.write((asString(body["text"]) ?? "") + "\n"); + return; + } + + const body = await apiRequest("GET", "/attention"); + if (jsonMode) { + process.stdout.write(JSON.stringify(body, null, 2) + "\n"); + return; + } + + process.stdout.write(`Blocked: ${String(body["blocked"] ?? 0)}\n`); + process.stdout.write(`Failed: ${String(body["failed"] ?? 0)}\n`); + process.stdout.write(`Stalled: ${String(body["stalled"] ?? 0)}\n`); + process.stdout.write(`Exhausted: ${String(body["exhausted"] ?? 0)}\n`); + + const tasksObj = asRecord(body["tasks"]); + if (!tasksObj) return; + + for (const key of ["blocked", "failed", "stalled", "exhausted"]) { + const list = asArray<unknown>(tasksObj[key]) + .map((entry) => asRecord(entry)) + .filter((entry): entry is Record<string, unknown> => entry !== null); + if (list.length === 0) continue; + + process.stdout.write(`\n${key.toUpperCase()}:\n`); + for (const task of list) { + process.stdout.write(` T${getString(task, "id")} [${getString(task, "priority", "medium")}] ${getString(task, "title")}\n`); + } + } +} + +function parseDependsOn(flags: Record<string, FlagValue>): string[] { + return getFlagList(flags, "depends-on").map((id) => normalizeTaskId(id)); +} + +async function cmdCreate(argv: string[], jsonMode: boolean): Promise<void> { + requireAgentId(); + + const { positionals, flags } = parseFlags(argv); + const title = ensureText(positionals[0], "title"); + const description = ensureText(getFlagString(flags, "description"), "--description"); + + const body: Record<string, unknown> = { + title, + description, + }; + + const assignee = getFlagString(flags, "assignee"); + const reviewer = getFlagString(flags, "reviewer"); + const consulted = getFlagString(flags, "consulted"); + const priority = getFlagString(flags, "priority"); + const informed = getFlagList(flags, "informed"); + const dependsOn = parseDependsOn(flags); + const repo = getFlagString(flags, "repo"); + const baseBranch = getFlagString(flags, "base-branch"); + + if (assignee) body["assignee"] = assignee; + if (reviewer) body["reviewer"] = reviewer; + if (consulted) body["consulted"] = consulted; + if (priority) body["priority"] = priority; + if (informed.length > 0) body["informed"] = informed; + if (dependsOn.length > 0) body["dependsOn"] = dependsOn; + if (repo) body["repo"] = repo; + if (baseBranch) body["baseBranch"] = baseBranch; + + const response = await apiRequest("POST", "/tasks", body); + + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + + const taskId = getString(response, "taskId", "?"); + process.stdout.write(`Created T${taskId}: ${title}\n`); + process.stdout.write(`Phase: ${getString(response, "phase", "analysis")}, Condition: ${getString(response, "condition", "ready")}\n`); + if (assignee) process.stdout.write(`Dispatcher will auto-assign to ${assignee}.\n`); + if (dependsOn.length > 0) process.stdout.write(`Waiting on ${dependsOn.map((id) => `T${id}`).join(", ")} before starting.\n`); + process.stdout.write(`Hint: task show ${taskId}\n`); +} + +async function cmdClaim(argv: string[], jsonMode: boolean): Promise<void> { + const agentId = requireAgentId(); + const { positionals } = parseFlags(argv); + const taskId = normalizeTaskId(ensureText(positionals[0], "task id")); + + const response = await apiRequest("POST", `/tasks/${taskId}/claim`, { + agentId, + source: "task-cli", + }); + + const task = asRecord(response["task"]); + if (!task) throw new CliError("Malformed claim response: missing task payload.", 2); + + const workspace = asRecord(response["workspace"]); + const journalWorktree = asString(workspace?.["journalWorktree"]); + const journalPath = asString(workspace?.["journalPath"]); + const codeWorktree = asString(workspace?.["codeWorktree"]); + + const sessionId = ensureText(asString(response["sessionId"]) ?? "", "sessionId"); + const fenceToken = asNumber(response["fenceToken"]); + if (fenceToken === null) throw new CliError("Malformed claim response: missing fenceToken.", 2); + + const context: TaskContext = { + taskId, + phase: asString(task["phase"]), + fenceToken, + sessionId, + journalPath: journalPath ?? "", + codeWorktree: codeWorktree ?? null, + claimedAt: Date.now(), + reviewNotes: [], + }; + + const roots: string[] = []; + if (journalWorktree) roots.push(journalWorktree); + if (codeWorktree) roots.push(codeWorktree); + if (roots.length > 0 && journalPath) { + writeTaskContext(context, roots); + } + + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + + process.stdout.write(`--- Claimed T${taskId}: ${getString(task, "title", "(untitled)")} ---\n`); + const leaseTimeout = asNumber(response["leaseTimeout"]); + if (leaseTimeout !== null) { + process.stdout.write(`Lease: ${Math.round(leaseTimeout / 60000)} min (extend with \`task extend\`)\n`); + } + process.stdout.write(`Fence: ${fenceToken}\n`); + + process.stdout.write("\n## Description\n"); + process.stdout.write(getString(task, "description", "(none)") + "\n"); + + process.stdout.write("\n## Your Workspace\n"); + if (journalPath) process.stdout.write(` Journal: ${journalPath}\n`); + if (codeWorktree) { + process.stdout.write(` Code: ${codeWorktree}\n\n`); + process.stdout.write(` cd ${codeWorktree}\n`); + } + + const failures = asArray<unknown>(task["failureSummaries"]) + .map((entry) => asRecord(entry)) + .filter((entry): entry is Record<string, unknown> => entry !== null); + if (failures.length > 0) { + process.stdout.write(`\n## Previous Attempts (${failures.length} failed)\n`); + failures.forEach((failure, idx) => { + process.stdout.write(` - Attempt ${idx + 1}: ${getString(failure, "whatFailed", "(unknown)")}\n`); + const learned = getString(failure, "whatWasLearned", ""); + if (learned) process.stdout.write(` Learned: ${learned}\n`); + }); + } + + const reviewState = asRecord(task["reviewState"]); + const verdicts = asArray<unknown>(reviewState?.["verdicts"]) + .map((entry) => asRecord(entry)) + .filter((entry): entry is Record<string, unknown> => entry !== null); + if (verdicts.length > 0) { + process.stdout.write("\n## Review Feedback\n"); + for (const verdict of verdicts) { + process.stdout.write(` Round ${getNumber(verdict, "round", 0)} (${getString(verdict, "reviewer", "reviewer")}): ${getString(verdict, "verdict", "unknown")}\n`); + const reasoning = getString(verdict, "reasoning", ""); + if (reasoning) process.stdout.write(` \"${reasoning}\"\n`); + } + } + + const parentJournal = asString(response["parentJournal"]); + if (parentJournal) { + process.stdout.write("\n## Parent Context\n"); + process.stdout.write(parentJournal + "\n"); + } + + const siblingFailures = asArray<unknown>(response["siblingFailures"]) + .map((entry) => asRecord(entry)) + .filter((entry): entry is Record<string, unknown> => entry !== null); + if (siblingFailures.length > 0) { + process.stdout.write("\n## Sibling Failures\n"); + for (const sibling of siblingFailures.slice(0, 5)) { + process.stdout.write(` T${getString(sibling, "taskId", "?")}: ${getString(sibling, "summary", "") || getString(sibling, "content", "")}`.trim() + "\n"); + } + } + + const workspaceConventions = asString(response["workspaceConventions"]); + if (workspaceConventions) { + process.stdout.write("\n## Workspace Conventions\n"); + process.stdout.write(workspaceConventions + "\n"); + } + + process.stdout.write("\n## What To Do Next\n"); + process.stdout.write(" 1. Read the code in the worktree\n"); + process.stdout.write(" 2. Write observations to your journal:\n"); + process.stdout.write(" task journal write \"Starting analysis...\"\n"); + process.stdout.write(" 3. When done:\n"); + process.stdout.write(" task submit \"Description of what you did\"\n"); + process.stdout.write(" 4. If blocked:\n"); + process.stdout.write(" task block \"What is preventing progress\"\n"); +} + +function sourceFor(agentId: string): Record<string, string> { + return { type: "agent", id: agentId }; +} + +async function cmdRelease(argv: string[], jsonMode: boolean): Promise<void> { + const agentId = requireAgentId(); + const { flags } = parseFlags(argv); + const taskId = currentTaskId(); + const task = await getTask(taskId); + + const fenceToken = getNumber(task, "currentFenceToken", -1); + const phase = getString(task, "phase", "execution"); + if (fenceToken < 0) throw new CliError(`Task ${taskId} has no active lease to release.`, 1); + + const reason = getFlagString(flags, "reason") ?? "Released by task CLI"; + const worked = getFlagBool(flags, "worked"); + + const payload = { + type: "LeaseReleased", + taskId, + ts: Date.now(), + fenceToken, + reason, + phase, + workPerformed: worked, + source: sourceFor(agentId), + }; + + const response = await apiRequest("POST", `/tasks/${taskId}/events`, payload); + clearTaskContextFile(); + + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + + process.stdout.write(`Released T${taskId}.\n`); +} + +async function cmdExtend(argv: string[], jsonMode: boolean): Promise<void> { + const agentId = requireAgentId(); + const { flags } = parseFlags(argv); + const taskId = currentTaskId(); + const task = await getTask(taskId); + + const fenceToken = getNumber(task, "currentFenceToken", -1); + if (fenceToken < 0) throw new CliError(`Task ${taskId} has no active lease to extend.`, 1); + + const duration = parseDurationMs(getFlagString(flags, "duration"), 15 * 60_000); + + const payload = { + type: "LeaseExtended", + taskId, + ts: Date.now(), + fenceToken, + leaseTimeout: duration, + source: sourceFor(agentId), + }; + + const response = await apiRequest("POST", `/tasks/${taskId}/events`, payload); + + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + + process.stdout.write(`Extended lease for T${taskId} by ${Math.round(duration / 60000)} min.\n`); +} + +async function cmdSubmit(argv: string[], jsonMode: boolean): Promise<void> { + requireAgentId(); + const evidence = ensureText(argv.join(" "), "evidence"); + const taskId = currentTaskId(); + + const response = await apiRequest("POST", `/tasks/${taskId}/status`, { + status: "review", + evidence, + }); + + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + + process.stdout.write(`Submitted T${taskId} for review.\n`); + process.stdout.write("Your work will be reviewed.\n"); +} + +async function cmdComplete(argv: string[], jsonMode: boolean): Promise<void> { + requireAgentId(); + const evidence = ensureText(argv.join(" "), "evidence"); + const taskId = currentTaskId(); + + const response = await apiRequest("POST", `/tasks/${taskId}/status`, { + status: "done", + evidence, + }); + + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + + process.stdout.write(`Completed T${taskId}.\n`); +} + +async function cmdBlock(argv: string[], jsonMode: boolean): Promise<void> { + requireAgentId(); + const reason = ensureText(argv.join(" "), "reason"); + const taskId = currentTaskId(); + + const response = await apiRequest("POST", `/tasks/${taskId}/status`, { + status: "blocked", + blocker: reason, + }); + + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + + process.stdout.write(`Blocked T${taskId}.\n`); +} + +async function cmdCost(argv: string[], jsonMode: boolean): Promise<void> { + const agentId = requireAgentId(); + const amountRaw = argv[0]; + const amount = amountRaw ? Number(amountRaw) : NaN; + if (!Number.isFinite(amount) || amount < 0) { + throw new CliError("Amount must be a non-negative number.", 1); + } + + const taskId = currentTaskId(); + const task = await getTask(taskId); + const fenceToken = getNumber(task, "currentFenceToken", -1); + if (fenceToken < 0) throw new CliError(`Task ${taskId} has no active lease token.`, 1); + + const response = await apiRequest("POST", `/tasks/${taskId}/events`, { + type: "CostReported", + taskId, + ts: Date.now(), + fenceToken, + reportedCost: amount, + source: sourceFor(agentId), + }); + + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + + process.stdout.write(`Reported ${formatMoney(amount)} for T${taskId}.\n`); +} + +async function cmdUpdate(argv: string[], jsonMode: boolean): Promise<void> { + requireAgentId(); + const message = ensureText(argv.join(" "), "message"); + const taskId = currentTaskId(); + + const journalRes = await apiRequest("POST", `/tasks/${taskId}/journal`, { entry: message }); + const metadataRes = await apiRequest("PATCH", `/tasks/${taskId}/metadata`, { + last_update: message, + last_update_at: new Date().toISOString(), + reason: "progress update via task CLI", + }); + + if (jsonMode) { + process.stdout.write(JSON.stringify({ journal: journalRes, metadata: metadataRes }, null, 2) + "\n"); + return; + } + + process.stdout.write(`Recorded progress update for T${taskId}.\n`); +} + +async function cmdAnalyze(jsonMode: boolean): Promise<void> { + const taskId = currentTaskId(); + const task = await getTask(taskId); + + if (jsonMode) { + process.stdout.write(JSON.stringify({ task }, null, 2) + "\n"); + return; + } + + process.stdout.write(`--- Analysis: T${taskId} — ${getString(task, "title", "(untitled)")} ---\n\n`); + process.stdout.write("## Task Description\n"); + process.stdout.write(getString(task, "description", "(none)") + "\n"); + + const failures = asArray<unknown>(task["failureSummaries"]) + .map((entry) => asRecord(entry)) + .filter((entry): entry is Record<string, unknown> => entry !== null); + if (failures.length > 0) { + process.stdout.write(`\n## Previous Approaches (${failures.length} failed)\n`); + failures.forEach((failure, idx) => { + process.stdout.write(` v${idx + 1}: ${getString(failure, "whatFailed", "(unknown)")}\n`); + }); + } + + process.stdout.write("\n## Considerations\n"); + process.stdout.write(" - Is this simple enough for a single agent?\n"); + process.stdout.write(" - Should it be decomposed into subtasks?\n"); + process.stdout.write(" - Is it blocked or missing information?\n"); + + process.stdout.write("\n## Your Decision\n"); + process.stdout.write(" task decide execute\n"); + process.stdout.write(" task decide decompose\n"); + process.stdout.write(" task block \"reason\"\n"); +} + +async function cmdDecide(argv: string[], jsonMode: boolean): Promise<void> { + requireAgentId(); + const decision = argv[0]?.trim(); + if (decision !== "execute" && decision !== "decompose") { + throw new CliError("Usage: task decide <execute|decompose>", 1); + } + + const taskId = currentTaskId(); + const status = decision === "execute" ? "execute" : "decompose"; + const response = await apiRequest("POST", `/tasks/${taskId}/status`, { status }); + + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + + if (decision === "execute") { + process.stdout.write(`Decision recorded: T${taskId} will execute directly.\n`); + } else { + process.stdout.write(`Decision recorded: T${taskId} will be decomposed.\n`); + } +} + +function parseSiblingDeps(raw: string[]): number[] { + const indices = raw.map((value) => Number.parseInt(value, 10)); + for (const idx of indices) { + if (!Number.isInteger(idx) || idx < 0) { + throw new CliError("--depends-on must contain non-negative sibling indices.", 1); + } + } + return indices; +} + +async function cmdDecompose(argv: string[], jsonMode: boolean): Promise<void> { + requireAgentId(); + const sub = argv[0]; + const args = argv.slice(1); + const taskId = currentTaskId(); + + switch (sub) { + case "start": { + const response = await apiRequest("POST", `/tasks/${taskId}/decompose/start`, {}); + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + + process.stdout.write(`--- Decomposition: T${taskId} ---\n`); + process.stdout.write(`Budget remaining: ${formatMoney(response["budgetRemaining"])}\n`); + process.stdout.write(`Children so far: ${String(response["childrenSoFar"] ?? 0)}\n\n`); + process.stdout.write(`task decompose add \"Child title\" --desc \"What this child should do\" --cost 10\n`); + return; + } + + case "add": { + const { positionals, flags } = parseFlags(args); + const title = ensureText(positionals[0], "child title"); + const desc = ensureText(getFlagString(flags, "desc"), "--desc"); + const costRaw = getFlagString(flags, "cost"); + const cost = costRaw ? Number(costRaw) : NaN; + if (!Number.isFinite(cost) || cost <= 0) { + throw new CliError("--cost must be a positive number.", 1); + } + + const depends = parseSiblingDeps(getFlagList(flags, "depends-on")); + + const body: Record<string, unknown> = { + title, + description: desc, + costAllocation: cost, + }; + + const assignee = getFlagString(flags, "assignee"); + const reviewer = getFlagString(flags, "reviewer"); + if (assignee) body["assignee"] = assignee; + if (reviewer) body["reviewer"] = reviewer; + if (depends.length > 0) body["dependsOnSiblings"] = depends; + if (getFlagBool(flags, "skip-analysis")) body["skipAnalysis"] = true; + + const response = await apiRequest("POST", `/tasks/${taskId}/decompose/add-child`, body); + + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + + process.stdout.write(`--- Child #${String(response["childIndex"] ?? "?")} added ---\n`); + process.stdout.write(` Title: ${title}\n`); + process.stdout.write(` Cost: ${formatMoney(cost)}\n`); + process.stdout.write(`\nBudget remaining: ${formatMoney(response["budgetRemaining"])}\n`); + process.stdout.write("\nNext:\n"); + process.stdout.write(" task decompose add \"Next title\" --desc \"...\" --cost N\n"); + process.stdout.write(" task decompose commit \"Strategy description\"\n"); + return; + } + + case "commit": { + const strategy = ensureText(args.join(" "), "strategy description"); + const response = await apiRequest("POST", `/tasks/${taskId}/decompose/commit`, { approach: strategy }); + + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + + process.stdout.write("--- Decomposition committed ---\n\n"); + process.stdout.write(`Strategy: ${strategy}\n\n`); + const children = asArray<unknown>(response["children"]) + .map((child) => asRecord(child)) + .filter((child): child is Record<string, unknown> => child !== null); + process.stdout.write(`Created ${children.length} children:\n`); + for (const child of children) { + process.stdout.write(` T${getString(child, "id", "?")}: ${getString(child, "title", "(untitled)")} ${formatMoney(child["costAllocation"])}\n`); + } + return; + } + + case "cancel": { + const response = await apiRequest("POST", `/tasks/${taskId}/decompose/cancel`, {}); + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + process.stdout.write(`Canceled pending decomposition session for T${taskId}.\n`); + return; + } + + default: + throw new CliError("Usage: task decompose <start|add|commit|cancel> ...", 1); + } +} + +function ensureContextWithTaskId(taskId: string): TaskContext { + const info = readTaskContext(); + if (!info.context || normalizeTaskId(info.context.taskId) !== normalizeTaskId(taskId)) { + return { + taskId, + phase: null, + fenceToken: 0, + sessionId: "", + journalPath: "", + codeWorktree: null, + claimedAt: Date.now(), + reviewNotes: [], + }; + } + return info.context; +} + +function persistReviewNotes(taskId: string, notes: string[]): void { + const { context, filePath } = readTaskContext(); + if (!context || !filePath || normalizeTaskId(context.taskId) !== normalizeTaskId(taskId)) return; + context.reviewNotes = notes; + fs.writeFileSync(filePath, JSON.stringify(context, null, 2) + "\n", "utf-8"); +} + +function getReviewEvidence(taskId: string, explicit: string | null): string { + if (explicit && explicit.trim()) return explicit.trim(); + const context = ensureContextWithTaskId(taskId); + const notes = context.reviewNotes ?? []; + if (notes.length === 0) return "Review completed."; + return notes.map((note, idx) => `${idx + 1}. ${note}`).join("\n"); +} + +async function cmdReview(argv: string[], jsonMode: boolean): Promise<void> { + requireAgentId(); + + const sub = argv[0]; + const args = argv.slice(1); + const taskId = currentTaskId(); + + switch (sub) { + case "read": { + const response = await apiRequest("GET", `/tasks/${taskId}/review/context`); + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + process.stdout.write(getString(response, "text", "No review context available.") + "\n"); + return; + } + + case "note": { + const note = ensureText(args.join(" "), "note"); + const response = await apiRequest("POST", `/tasks/${taskId}/review/note`, { note }); + + const notes = asArray<unknown>(response["notes"]).map((entry) => asString(entry)).filter((entry): entry is string => entry !== null); + persistReviewNotes(taskId, notes); + + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + + process.stdout.write(`--- Note recorded (${notes.length} total) ---\n\n`); + process.stdout.write("Your notes so far:\n"); + notes.forEach((entry, idx) => process.stdout.write(` ${idx + 1}. ${entry}\n`)); + process.stdout.write("\n task review note \"Another observation\"\n"); + process.stdout.write(" task review approve \"Summary\"\n"); + process.stdout.write(" task review reject \"Reason\"\n"); + process.stdout.write(" task review request-changes \"Feedback\"\n"); + return; + } + + case "approve": { + const evidence = getReviewEvidence(taskId, args.join(" ")); + const response = await apiRequest("POST", `/tasks/${taskId}/status`, { + status: "done", + evidence, + }); + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + process.stdout.write(`Approved T${taskId}.\n`); + return; + } + + case "reject": { + const evidence = getReviewEvidence(taskId, args.join(" ")); + const response = await apiRequest("POST", `/tasks/${taskId}/status`, { + status: "reject", + evidence, + }); + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + process.stdout.write(`Rejected T${taskId}.\n`); + return; + } + + case "request-changes": { + const evidence = getReviewEvidence(taskId, args.join(" ")); + const response = await apiRequest("POST", `/tasks/${taskId}/status`, { + status: "pending", + evidence, + }); + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + process.stdout.write("--- Changes requested ---\n\n"); + process.stdout.write(`T${taskId} returns to execution.\n`); + process.stdout.write(`Feedback: \"${evidence}\"\n`); + return; + } + + default: + throw new CliError("Usage: task review <read|note|approve|reject|request-changes> ...", 1); + } +} + +async function cmdJournal(argv: string[], jsonMode: boolean): Promise<void> { + requireAgentId(); + const sub = argv[0]; + const args = argv.slice(1); + const taskId = currentTaskId(); + + switch (sub) { + case "read": { + const response = await apiRequest("GET", `/tasks/${taskId}/journal`); + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + process.stdout.write(getString(response, "content", "") + "\n"); + return; + } + + case "write": { + const entry = ensureText(args.join(" "), "journal entry"); + const response = await apiRequest("POST", `/tasks/${taskId}/journal`, { entry }); + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + process.stdout.write(`Journal updated for T${taskId}.\n`); + return; + } + + case "write-file": { + const fileName = ensureText(args[0], "file name"); + const content = ensureText(args.slice(1).join(" "), "file content"); + const response = await apiRequest("POST", `/tasks/${taskId}/journal/file`, { + name: fileName, + content, + }); + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + process.stdout.write(`Wrote ${fileName} to journal for T${taskId}.\n`); + return; + } + + default: + throw new CliError("Usage: task journal <read|write|write-file> ...", 1); + } +} + +function taskFromContextOrFail(): TaskContext { + const { context } = readTaskContext(); + if (!context) throw new CliError("No .task context found in current directory tree.", 1); + return context; +} + +function cmdWorktree(): void { + const context = taskFromContextOrFail(); + process.stdout.write(`Journal: ${context.journalPath}\n`); + process.stdout.write(`Code: ${context.codeWorktree ?? "(none)"}\n`); +} + +async function cmdRevive(argv: string[], jsonMode: boolean): Promise<void> { + requireAgentId(); + const { positionals, flags } = parseFlags(argv); + const taskId = normalizeTaskId(ensureText(positionals[0], "task id")); + const reason = getFlagString(flags, "reason") ?? "New approach available"; + + const response = await apiRequest("POST", `/tasks/${taskId}/revive`, { reason }); + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + process.stdout.write(`Revived T${taskId}.\n`); +} + +async function cmdCancel(argv: string[], jsonMode: boolean): Promise<void> { + requireAgentId(); + const { positionals, flags } = parseFlags(argv); + const taskId = normalizeTaskId(ensureText(positionals[0], "task id")); + const reason = getFlagString(flags, "reason") ?? "No longer needed"; + + const response = await apiRequest("POST", `/tasks/${taskId}/status`, { status: "cancel", evidence: reason }); + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + process.stdout.write(`Canceled T${taskId}.\n`); +} + +function parseAttemptBudget(raw: string[]): Record<string, { max: number }> { + const out: Record<string, { max: number }> = {}; + for (const entry of raw) { + const [phase, maxRaw] = entry.split(":"); + const max = Number.parseInt(maxRaw ?? "", 10); + if (!phase || !Number.isInteger(max) || max <= 0) { + throw new CliError(`Invalid attempt budget '${entry}'. Use phase:max (e.g. execution:4).`, 1); + } + out[phase] = { max }; + } + return out; +} + +async function cmdBudget(argv: string[], jsonMode: boolean): Promise<void> { + requireAgentId(); + const { positionals, flags } = parseFlags(argv); + const taskId = normalizeTaskId(ensureText(positionals[0], "task id")); + + const costRaw = getFlagString(flags, "cost"); + const attemptsRaw = getFlagList(flags, "attempts"); + + const body: Record<string, unknown> = { + reason: "budget updated via task CLI", + }; + + if (costRaw) { + const cost = Number(costRaw); + if (!Number.isFinite(cost) || cost <= 0) { + throw new CliError("--cost must be a positive number.", 1); + } + body["costBudgetIncrease"] = cost; + } + + if (attemptsRaw.length > 0) { + body["attemptBudgetIncrease"] = parseAttemptBudget(attemptsRaw); + } + + if (body["costBudgetIncrease"] === undefined && body["attemptBudgetIncrease"] === undefined) { + throw new CliError("Provide at least one of --cost or --attempts.", 1); + } + + const response = await apiRequest("POST", `/tasks/${taskId}/budget`, body); + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + + process.stdout.write(`Updated budget for T${taskId}.\n`); +} + +function parseMetadataValue(raw: string): unknown { + if (raw === "null") return null; + if (raw === "true") return true; + if (raw === "false") return false; + const n = Number(raw); + if (Number.isFinite(n) && raw.trim() !== "") return n; + if (raw.includes(",")) { + return raw.split(",").map((item) => item.trim()).filter(Boolean); + } + return raw; +} + +async function cmdMetadata(argv: string[], jsonMode: boolean): Promise<void> { + requireAgentId(); + const taskId = normalizeTaskId(ensureText(argv[0], "task id")); + const key = ensureText(argv[1], "metadata key"); + const value = ensureText(argv.slice(2).join(" "), "metadata value"); + + const patch: Record<string, unknown> = { + [key]: parseMetadataValue(value), + reason: "metadata updated via task CLI", + }; + + const response = await apiRequest("PATCH", `/tasks/${taskId}/metadata`, patch); + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + process.stdout.write(`Updated metadata for T${taskId}.\n`); +} + +async function cmdReparent(argv: string[], jsonMode: boolean): Promise<void> { + requireAgentId(); + const { positionals, flags } = parseFlags(argv); + const taskId = normalizeTaskId(ensureText(positionals[0], "task id")); + const parent = normalizeTaskId(ensureText(getFlagString(flags, "parent"), "--parent")); + + const response = await apiRequest("POST", `/tasks/${taskId}/reparent`, { newParentId: parent }); + if (jsonMode) { + process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + return; + } + process.stdout.write(`Reparented T${taskId} under T${parent}.\n`); +} + +async function cmdIncident(argv: string[], jsonMode: boolean): Promise<void> { + requireAgentId(); + const { positionals, flags } = parseFlags(argv); + const summary = ensureText(positionals.join(" "), "summary"); + const severity = ensureText(getFlagString(flags, "severity"), "--severity"); + const category = ensureText(getFlagString(flags, "category"), "--category"); + const detail = getFlagString(flags, "detail"); + const tags = getFlagList(flags, "tags"); + + const workspaceDir = process.env["WORKSPACE_DIR"] + ?? process.env["OPENCLAW_STATE_DIR"] + ?? path.join(os.homedir(), ".openclaw", "workspace"); + + const incidentDir = path.join(workspaceDir, "data", "incidents"); + fs.mkdirSync(incidentDir, { recursive: true }); + + const incidentId = `INC-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const incident = { + id: incidentId, + ts: new Date().toISOString(), + severity, + category, + summary, + detail: detail ?? null, + tags, + source: "task-cli", + }; + + const date = new Date().toISOString().slice(0, 10); + fs.appendFileSync(path.join(incidentDir, `${date}.jsonl`), JSON.stringify(incident) + "\n", "utf-8"); + + if (jsonMode) { + process.stdout.write(JSON.stringify({ incident_id: incidentId, status: "recorded" }, null, 2) + "\n"); + return; + } + process.stdout.write(`Incident recorded: ${incidentId}\n`); +} + +function extractJsonFlag(argv: string[]): { jsonMode: boolean; args: string[] } { + let jsonMode = false; + const args: string[] = []; + + for (const token of argv) { + if (token === "--json") { + jsonMode = true; + continue; + } + args.push(token); + } + + return { jsonMode, args }; +} + +async function run(argv: string[]): Promise<void> { + const { jsonMode, args } = extractJsonFlag(argv); + const command = args[0]; + const rest = args.slice(1); + + if (!command || command === "help" || command === "--help" || command === "-h") { + printHelp(); + return; + } + + switch (command) { + case "list": + await cmdList(rest, jsonMode); + return; + case "show": + await cmdShow(rest, jsonMode); + return; + case "events": + await cmdEvents(rest, jsonMode); + return; + case "attention": + await cmdAttention(rest, jsonMode); + return; + case "create": + await cmdCreate(rest, jsonMode); + return; + case "claim": + await cmdClaim(rest, jsonMode); + return; + case "release": + await cmdRelease(rest, jsonMode); + return; + case "extend": + await cmdExtend(rest, jsonMode); + return; + case "submit": + await cmdSubmit(rest, jsonMode); + return; + case "complete": + await cmdComplete(rest, jsonMode); + return; + case "block": + await cmdBlock(rest, jsonMode); + return; + case "cost": + await cmdCost(rest, jsonMode); + return; + case "update": + await cmdUpdate(rest, jsonMode); + return; + case "analyze": + await cmdAnalyze(jsonMode); + return; + case "decide": + await cmdDecide(rest, jsonMode); + return; + case "decompose": + await cmdDecompose(rest, jsonMode); + return; + case "review": + await cmdReview(rest, jsonMode); + return; + case "journal": + await cmdJournal(rest, jsonMode); + return; + case "worktree": + cmdWorktree(); + return; + case "revive": + await cmdRevive(rest, jsonMode); + return; + case "cancel": + await cmdCancel(rest, jsonMode); + return; + case "budget": + await cmdBudget(rest, jsonMode); + return; + case "metadata": + await cmdMetadata(rest, jsonMode); + return; + case "reparent": + await cmdReparent(rest, jsonMode); + return; + case "incident": + await cmdIncident(rest, jsonMode); + return; + default: + throw new CliError(`Unknown command: ${command}`, 1); + } +} + +run(process.argv.slice(2)).catch((err: unknown) => { + if (err instanceof CliError) { + process.stderr.write(err.message + "\n"); + process.exit(err.code); + return; + } + + if (err instanceof ApiError) { + const body = asRecord(err.body); + if (body) { + const errorCode = asString(body["error"]); + const message = asString(body["message"]); + if (errorCode) { + process.stderr.write(`${errorCode}: ${message ?? "API request failed"}\n`); + } else if (message) { + process.stderr.write(`${message}\n`); + } else { + process.stderr.write(`${JSON.stringify(body)}\n`); + } + } else { + process.stderr.write(String(err.body) + "\n"); + } + process.exit(2); + return; + } + + process.stderr.write(`Unexpected error: ${String(err)}\n`); + process.exit(1); +}); diff --git a/core/clock.ts b/core/clock.ts index 52ee938..f365bcc 100644 --- a/core/clock.ts +++ b/core/clock.ts @@ -126,7 +126,11 @@ export class CoreClock { continue; } - if (task.condition === "leased" && task.leaseExpiresAt !== null && task.leaseExpiresAt <= now) { + if ( + (task.condition === "leased" || task.condition === "active") && + task.leaseExpiresAt !== null && + task.leaseExpiresAt <= now + ) { const leaseExpired: LeaseExpired = { type: "LeaseExpired", taskId: task.id, diff --git a/core/docs/task-cli-spec.md b/core/docs/task-cli-spec.md new file mode 100644 index 0000000..95d6c57 --- /dev/null +++ b/core/docs/task-cli-spec.md @@ -0,0 +1,868 @@ +# `task` CLI Specification + +> Status: Draft v1 — 2026-03-05 + +## 1. Design Principles + +1. **Context delivery, not just actions.** Every command returns rich, relevant + context the agent needs for its current step. The CLI replaces the giant + dispatch prompt with incremental, on-demand context. + +2. **Multi-step guided workflows.** Complex flows (decomposition, review, + analysis) are broken into small steps. Each step's output includes the exact + command(s) to run next. One decision per invocation — keeps agent output small + and focused. + +3. **Current-task context (git-style).** After `task claim`, subsequent commands + operate on the claimed task implicitly. State lives in a `.task` file (like + `.git/HEAD`), searched upward from cwd. + +4. **Worktree-native.** `task claim` creates journal + code worktrees and writes + the `.task` context file at the worktree root. The agent works inside the + worktree. Everything is scoped. + +5. **Replaces MCP entirely.** This CLI is the sole interface between agents and + taskcore. The MCP server (`openclaw-mcp-server.mjs`) is retired. + +6. **Human-readable output first.** Agents consume the same text humans would + read. `--json` flag reserved for future machine parsing. + +## 2. Identity & Context + +### Agent Identity + +``` +export TASKCORE_AGENT_ID=coder +``` + +Set by the dispatcher or manually. Required for `claim`, `complete`, and other +state-changing commands. Read-only commands (`list`, `show`) work without it. + +### Current Task (`.task` file) + +After `task claim <id>`, the CLI writes a `.task` file at the worktree root: + +```json +{ + "taskId": "842", + "phase": "execution", + "fenceToken": 47, + "sessionId": "a1b2c3d4-...", + "journalPath": "/tmp/taskcore-worktrees/journal-T842/tasks/T842/", + "codeWorktree": "/tmp/taskcore-worktrees/code-T842/", + "claimedAt": 1709654400000 +} +``` + +The CLI searches upward from cwd for `.task` (same algorithm as git finding +`.git`). Falls back to `$TASK_ID` env var (set by the dispatcher for +auto-dispatched agents). + +When both `.task` and `$TASK_ID` exist, `.task` wins. + +### Worktree Setup on Claim + +`task claim <id>` performs: + +1. Calls `POST /tasks/:id/claim` (LeaseGranted + AgentStarted) +2. Creates journal worktree at `/tmp/taskcore-worktrees/journal-T{id}/` +3. Creates code worktree at `/tmp/taskcore-worktrees/code-T{id}/` (if task has + `metadata.repo`) +4. Writes `.task` file in **both** worktree roots (so the context is found + regardless of which worktree the agent cds into) +5. Prints the worktree paths and full task context + +The agent (or dispatcher wrapper) is responsible for `cd`-ing into the +appropriate worktree. The CLI cannot change the parent shell's cwd. + +## 3. Command Reference + +### 3.1 Discovery & Read Commands + +These require no identity and don't mutate state. + +#### `task list` + +List tasks with filters. Default: non-terminal tasks sorted by priority. + +``` +task list [--phase <phase>] [--condition <condition>] [--terminal <terminal>] + [--assignee <agent>] [--priority <priority>] [--parent <id>] + [--mine] [--limit N] +``` + +`--mine` filters to tasks assigned to `$TASKCORE_AGENT_ID`. + +**Output**: table with columns `ID | Priority | Phase | Condition | Assignee | Title`. + +Context provided: +- Total count and how many are shown +- Hint: `task show <id>` for details + +#### `task show <id>` + +Full task detail view. + +``` +task show <id> [--events] [--children] [--deps] +``` + +**Output** (rich context): + +``` +--- T842: Fix memory leak in connection pool --- +Priority: high +Phase: execution +Condition: ready +Assignee: coder +Reviewer: overseer +Parent: T800 (Reliability improvements) + +Created: 2026-03-04 14:30 UTC +Updated: 2026-03-05 09:15 UTC + +## Description +<full task description> + +## Attempts + execution: 2/8 used + analysis: 1/4 used + +## Cost + Allocated: $50.00 + Consumed: $12.30 + Remaining: $37.70 + +## Previous Failures + - Attempt 1: Connection pool timeout not handled (learned: need to mock timer) + - Attempt 2: Test passed locally but CI uses different pool size + +## Review Feedback + Round 1 (overseer): changes_requested + "Missing error handling for edge case when pool is at capacity" + +## Dependencies + T840 (done) -- pool refactor must land first + T841 (in-progress) -- shared test fixtures + +## Children + (none) +``` + +#### `task events <id>` + +Raw event history for a task. Useful for debugging. + +``` +task events <id> [--last N] +``` + +#### `task attention` + +Show tasks that need human attention (blocked, failed, exhausted). + +``` +task attention [--format telegram] +``` + +### 3.2 Task Lifecycle Commands + +These require `$TASKCORE_AGENT_ID`. + +#### `task create` + +Create a new task (replaces `delegate` MCP tool). + +``` +task create <title> --description <desc> + [--assignee <agent>] [--reviewer <agent>] [--consulted <agent>] + [--priority <priority>] [--depends-on <id>,<id>] + [--informed <target>,<target>] + [--repo <path>] [--base-branch <branch>] +``` + +**Output** (context): +- Created task ID, phase, condition +- If assignee set: "Dispatcher will auto-assign to <agent>" +- If depends-on: "Waiting on T840, T841 before starting" +- Hint: `task show <id>` for full details + +#### `task claim <id>` + +Claim a task and set up the workspace. + +``` +task claim <id> +``` + +**Output** (rich context — this is the agent's primary onboarding): + +``` +--- Claimed T842: Fix memory leak in connection pool --- +Lease: 15 min (extend with `task extend`) +Fence: 48 + +## Description +<full task description> + +## Your Workspace + Journal: /tmp/taskcore-worktrees/journal-T842/tasks/T842/ + Code: /tmp/taskcore-worktrees/code-T842/ + + cd /tmp/taskcore-worktrees/code-T842/ + +## Previous Attempts (2 failed) + - Attempt 1: Connection pool timeout not handled + Learned: need to mock timer in tests + - Attempt 2: Test passed locally but CI uses different pool size + Learned: always test with CI pool config + +## Review Feedback + Round 1 (overseer): changes_requested + "Missing error handling for edge case when pool is at capacity" + +## Parent Context (T800) + <truncated journal from parent task> + +## Sibling Failures + T838: Tried mutex approach, deadlocked under load + T839: Race condition in cleanup — fixed wrong lifecycle hook + +## Workspace Conventions + <AGENTS.md content> + +## What To Do Next + 1. Read the code in the worktree + 2. Write observations to your journal: + task journal write "Starting analysis of pool.ts..." + 3. When done: + task submit "Description of what you did" + 4. If blocked: + task block "What is preventing progress" +``` + +#### `task release` + +Release current task back to the queue. + +``` +task release [--reason <reason>] [--worked] +``` + +`--worked` counts as an attempt (increments attempt counter). + +#### `task extend` + +Extend the lease on the current task. + +``` +task extend [--duration <duration>] +``` + +Default: 15 minutes. Accepts: `10m`, `30m`, `1h`. + +### 3.3 Status Transitions + +All operate on the current task (from `.task` context). + +#### `task submit <evidence>` + +Submit work for review. Transitions execution.active -> review.ready. + +``` +task submit "Fixed the leak by adding cleanup in dispose(). Tests added." +``` + +**Output**: +- Confirmation + new phase/condition +- Who will review: "Reviewer: overseer" +- Hint: "Your work will be reviewed. You can work on another task or wait." + +#### `task complete <evidence>` + +Mark task as done (when there is no reviewer, or used by reviewer to approve). + +``` +task complete "All acceptance criteria met. Verified in staging." +``` + +#### `task block <reason>` + +Mark task as blocked. + +``` +task block "Need API credentials from Nicholas — can't test auth flow" +``` + +**Output**: +- Confirmation +- Informed parties notified (if any) +- Consulted agent suggestion: "Consider asking hermes for help" + +#### `task cost <amount>` + +Report incremental cost consumed. + +``` +task cost 0.15 +``` + +#### `task update <message>` + +Progress note — no state change, just a record. Written to both the event log +and the journal. + +``` +task update "Refactored parser module, still need to update tests" +``` + +### 3.4 Analysis Workflow (guided) + +When a task is in the `analysis` phase, the agent decides whether to execute +directly or decompose. + +#### `task analyze` + +Read the analysis context for the current task. + +``` +task analyze +``` + +**Output** (context-rich): + +``` +--- Analysis: T842 — Fix memory leak in connection pool --- + +## Task Description +<description> + +## Previous Approaches (1 failed) + v1: Direct fix attempt — failed (timeout not handled) + +## Considerations + - Is this simple enough for a single agent? + - Should it be decomposed into subtasks? + - Is it blocked or missing information? + +## Your Decision + task decide execute Execute directly (one agent can handle this) + task decide decompose Needs decomposition into subtasks + task block "reason" Cannot proceed +``` + +#### `task decide execute` + +Decision: task is simple enough for direct execution. + +``` +task decide execute +``` + +Transitions: analysis -> execution.ready. + +**Output**: confirmation + hint that dispatcher will assign. + +#### `task decide decompose` + +Decision: task needs decomposition. + +``` +task decide decompose +``` + +Transitions: analysis -> decomposition.ready. + +**Output**: confirmation + hint to wait for decomposition phase dispatch. + +### 3.5 Decomposition Workflow (guided, multi-step) + +When a task is in the `decomposition` phase. + +#### `task decompose start` + +Begin a decomposition session. Prints full context needed to plan subtasks. + +``` +task decompose start +``` + +**Output** (context): + +``` +--- Decomposition: T842 — Fix memory leak in connection pool --- + +## Task Description +<description> + +## Budget + Remaining: $50.00 + You must allocate cost to each child from this budget. + +## Previous Decompositions + (none — first attempt) + + OR: + + v1: "Split into find + fix + test" — FAILED + Child T843 (Find leaks) failed: couldn't reproduce in test env + Child T844 (Fix leaks) blocked: depended on T843 + DO NOT repeat this strategy. + +## Guidelines + - Each child should be completable by one agent in one session + - Children should be as independent as possible + - Use --depends-on when order matters (0-indexed sibling position) + - Leave assignee blank unless a specific agent is needed + +## Next Step + Add your first child: + + task decompose add "Child title" \ + --desc "What this child should do" \ + --cost 10 +``` + +#### `task decompose add` + +Add one child to the pending decomposition. + +``` +task decompose add <title> + --desc <description> + --cost <amount> + [--assignee <agent>] + [--reviewer <agent>] + [--depends-on <sibling-index>,<sibling-index>] + [--skip-analysis] +``` + +**Output** (context for next decision): + +``` +--- Child #0 added --- + Title: Find all leak sources + Cost: $10.00 + Assignee: analyst + +Budget remaining: $40.00 + +Children so far: + #0 Find all leak sources $10 analyst + +Add another child, or commit: + + task decompose add "Next title" --desc "..." --cost N + task decompose add "Next title" --desc "..." --cost N --depends-on 0 + task decompose commit "Brief strategy description" +``` + +After adding more children: + +``` +--- Child #2 added --- + Title: Regression tests + Cost: $15.00 + Depends: #1 (Fix identified leaks) + +Budget remaining: $5.00 + +Children so far: + #0 Find all leak sources $10 analyst + #1 Fix identified leaks $20 coder (depends on #0) + #2 Regression tests $15 coder (depends on #1) + + task decompose add "..." --desc "..." --cost N + task decompose commit "Strategy description" +``` + +#### `task decompose commit` + +Finalize the decomposition. Creates all children as real tasks. + +``` +task decompose commit "Audit-first: find leaks, fix them, then add regression tests" +``` + +**Output** (summary): + +``` +--- Decomposition committed --- + +Strategy: Audit-first: find leaks, fix them, then add regression tests + +Created 3 children: + T843: Find all leak sources $10 analyst (ready) + T844: Fix identified leaks $20 coder (waiting on T843) + T845: Regression tests $15 coder (waiting on T844) + +Budget remaining: $5.00 + +Your decomposition task T842 is complete. +The dispatcher will pick up the children. +``` + +#### `task decompose cancel` + +Abort a pending decomposition session without committing. + +``` +task decompose cancel +``` + +### 3.6 Review Workflow (guided, multi-step) + +When a task is in the `review` phase and claimed by a reviewer. + +#### `task review read` + +Load the review context. This is step 1 — the reviewer reads before judging. + +``` +task review read +``` + +**Output** (context — everything the reviewer needs): + +``` +--- Review: T842 — Fix memory leak in connection pool --- + +## Original Task +<task description> + +## Assignee Evidence +"Fixed the leak by adding cleanup in dispose(). Tests in pool.test.ts." + +## Agent Journal +<journal.md content from the task's journal branch> + +## Code Changes +<git diff from the task branch vs base> + +## Previous Review Rounds + (none — first review) + + OR: + + Round 1 (overseer): changes_requested + "Missing error handling for edge case when pool is at capacity" + +## Next Step + Record your observations, then submit a verdict: + + task review note "Observation about the code or evidence" + task review approve "Summary of why this passes" + task review reject "Summary of why this fails" + task review request-changes "What needs to change" +``` + +#### `task review note` + +Record an observation. Written to the reviewer's journal. Accumulates — the +agent can add multiple notes before deciding. Notes are included in the final +verdict context. + +``` +task review note "Code change looks correct. dispose() now calls pool.drain()" +task review note "Missing: no test for the case when drain() throws" +``` + +**Output** (context after each note): + +``` +--- Note recorded (2 total) --- + +Your notes so far: + 1. Code change looks correct. dispose() now calls pool.drain() + 2. Missing: no test for the case when drain() throws + + task review note "Another observation" + task review approve "Summary" + task review reject "Reason" + task review request-changes "Feedback" +``` + +#### `task review approve` + +Approve the work. Submits ReviewVerdictSubmitted(approve) + marks task done. + +``` +task review approve "Clean fix, tests pass, handles edge cases correctly" +``` + +#### `task review reject` + +Reject the work. Task moves to failed. + +``` +task review reject "Fundamental approach is wrong — pool should use weak refs" +``` + +#### `task review request-changes` + +Request changes. Task returns to execution.ready for the assignee to address. + +``` +task review request-changes "Add test for drain() throwing. Otherwise LGTM." +``` + +**Output**: + +``` +--- Changes requested --- + +Your feedback has been recorded. T842 returns to execution. +The assignee will see your feedback when re-dispatched. + +Feedback: "Add test for drain() throwing. Otherwise LGTM." +``` + +### 3.7 Journal Commands + +Agents write structured observations to their task journal. The journal is +persisted in a git branch and visible to reviewers and future retry attempts. + +#### `task journal read` + +Print the journal for the current task. + +``` +task journal read +``` + +#### `task journal write` + +Append a section to the journal. + +``` +task journal write "## Analysis\nThe connection pool uses a fixed-size array..." +``` + +Commits to the journal branch automatically. + +#### `task journal write-file` + +Write an artifact file to the journal directory (for reports, data, etc.). + +``` +task journal write-file analysis.csv "header1,header2\nval1,val2" +``` + +### 3.8 Worktree Commands + +#### `task worktree` + +Print worktree paths for the current task. + +``` +task worktree +``` + +**Output**: + +``` +Journal: /tmp/taskcore-worktrees/journal-T842/tasks/T842/ +Code: /tmp/taskcore-worktrees/code-T842/ +``` + +### 3.9 Admin Commands + +Available to all agents but intended for orchestration roles (overseer, hermes). +Could be role-gated later. + +#### `task revive <id>` + +Bring a failed or blocked task back to life. + +``` +task revive <id> [--reason "New approach available"] +``` + +#### `task cancel <id>` + +Cancel a task. + +``` +task cancel <id> [--reason "No longer needed"] +``` + +#### `task budget <id>` + +Increase attempt or cost budget. + +``` +task budget <id> --cost 20 +task budget <id> --attempts execution:4 +``` + +#### `task metadata <id> <key> <value>` + +Update task metadata. + +``` +task metadata 842 priority critical +task metadata 842 assignee coder-lite +``` + +#### `task reparent <id> --parent <parent-id>` + +Move a task under a different parent. + +``` +task reparent 845 --parent 900 +``` + +### 3.10 Incidents + +#### `task incident` + +Report an incident. + +``` +task incident <summary> + --severity <critical|error|warning|info> + --category <category> + [--detail "Extended description"] + [--tags tag1,tag2] +``` + +## 4. Multi-Step Workflow Patterns + +The CLI uses a **stateful command chain** pattern for complex workflows. The +mechanism: + +1. Each command performs **one action** and returns **context for the next step** +2. The output includes **suggested next commands** (copy-pasteable) +3. State is tracked in the `.task` context file (updated after each command) +4. The agent makes **one small decision** per invocation + +This keeps agent responses short and focused. Instead of a 2000-token reasoning +blob, the agent produces a 50-token decision + runs the next command. + +### 4.1 Execution Flow (typical) + +``` +task claim 842 + -> reads context, cds into worktree +task journal write "Starting work on pool cleanup..." + -> records observation + ... agent does actual coding work ... +task journal write "## Changes\n- Added drain() call in dispose()\n- ..." +task cost 0.08 +task submit "Fixed leak: added drain() in dispose(), tests in pool.test.ts" +``` + +### 4.2 Analysis Flow + +``` +task claim 842 + -> context shows analysis phase +task analyze + -> reads task, previous failures, approaches +task journal write "## Analysis\nThis is simple enough for one agent because..." +task decide execute +``` + +### 4.3 Decomposition Flow + +``` +task claim 842 + -> context shows decomposition phase +task decompose start + -> budget, failed approaches, guidelines +task journal write "## Strategy\nSplitting into audit + fix + test..." +task decompose add "Audit pool" --desc "Find all leaks" --cost 10 + -> child #0 added, budget remaining +task decompose add "Fix leaks" --desc "..." --cost 20 --depends-on 0 + -> child #1 added +task decompose add "Tests" --desc "..." --cost 15 --depends-on 1 + -> child #2 added +task decompose commit "Audit-first: find, fix, test" + -> done, children created +``` + +### 4.4 Review Flow + +``` +task claim 842 + -> context shows review phase +task review read + -> task description, evidence, diff, journal +task review note "Code change is correct, drain() properly called" +task review note "Missing test for drain() exception" +task review request-changes "Add test for drain() throwing" + -> T842 returns to execution +``` + +## 5. Context Delivery Summary + +What each command delivers (beyond its primary action): + +| Command | Context Delivered | +|---------|-------------------| +| `task claim` | Full task description, failures, review feedback, parent journal, sibling failures, workspace conventions, worktree paths | +| `task analyze` | Task description, previous approaches, failure history | +| `task decompose start` | Task description, budget, failed decompositions, guidelines | +| `task decompose add` | Running child list, remaining budget, dependency graph | +| `task review read` | Original task, evidence, code diff, journal, previous rounds | +| `task review note` | Running notes list | +| `task show` | Everything: description, attempts, cost, failures, reviews, deps, children | +| `task list --mine` | My tasks sorted by priority, phases, conditions | + +## 6. Implementation Notes + +### Language & Location + +TypeScript, lives in the taskcore repo at `core/cli/`. Shares types with core. +Compiles to a single executable via the existing build pipeline. + +Installed as `task` (symlink or bin entry in package.json). + +### Communication + +All commands talk to the taskcore daemon via HTTP (`http://127.0.0.1:18800`). +Port configurable via `$ORCHESTRATOR_PORT`. + +The CLI does NOT import core directly — it's a pure HTTP client. This means it +works from any machine that can reach the daemon (including inside worktrees, +containers, etc.). + +### Error Output + +Errors go to stderr. Successful context goes to stdout. This allows: + +```bash +WORKTREE=$(task claim 842 2>/dev/null | grep "^Code:" | awk '{print $2}') +cd "$WORKTREE" +``` + +### Exit Codes + +- 0: success +- 1: command error (bad args, missing context) +- 2: API error (daemon unreachable, task not found, invalid state transition) +- 3: auth error (no TASKCORE_AGENT_ID when required) + +### MCP Cutover Plan + +1. Build CLI with feature parity to MCP tools +2. Update dispatcher to use `task` CLI in agent prompts instead of curl examples +3. Update agent CLAUDE.md / AGENTS.md to reference `task` commands +4. Remove MCP server from `.mcp.json` +5. Delete `openclaw-mcp-server.mjs` + +## 7. Open Questions + +1. **Shell wrapper for auto-cd**: Should we provide a shell function that wraps + `task claim` to auto-cd into the worktree? Or leave that to the dispatcher? + +2. **Streaming output**: Should `task show` or `task review read` page long + output, or just dump everything? (Agents don't paginate, so probably dump.) + +3. **Offline mode**: Should the CLI cache task data for when the daemon is + unreachable? (Probably not for v1.) + +4. **Dispatcher integration**: The dispatcher currently spawns `openclaw agent`. + Should it switch to having agents self-serve via `task claim` + `task submit`? + Or keep the dispatcher for auto-dispatch and have the CLI for self-directed + work? diff --git a/core/reducer.ts b/core/reducer.ts index 1d6cf12..c227405 100644 --- a/core/reducer.ts +++ b/core/reducer.ts @@ -280,6 +280,25 @@ function applyLeaseExpired(state: SystemState, event: Extract<Event, { type: "Le state.tasks[t.id] = t; } +function applyLeaseReleased(state: SystemState, event: Extract<Event, { type: "LeaseReleased" }>, task: Task): void { + const t = deepCloneTask(task); + t.condition = "ready"; + t.leasedTo = null; + t.leaseExpiresAt = null; + t.retryAfter = null; + t.currentSessionId = null; + t.lastAgentExitAt = null; + t.updatedAt = event.ts; + state.tasks[t.id] = t; +} + +function applyLeaseExtended(state: SystemState, event: Extract<Event, { type: "LeaseExtended" }>, task: Task): void { + const t = deepCloneTask(task); + t.leaseExpiresAt = event.ts + event.leaseTimeout; + t.updatedAt = event.ts; + state.tasks[t.id] = t; +} + function applyAgentStarted(state: SystemState, event: Extract<Event, { type: "AgentStarted" }>, task: Task): void { const t = deepCloneTask(task); t.condition = "active"; @@ -289,6 +308,13 @@ function applyAgentStarted(state: SystemState, event: Extract<Event, { type: "Ag state.tasks[t.id] = t; } +function applyCostReported(state: SystemState, event: Extract<Event, { type: "CostReported" }>, task: Task): void { + const t = deepCloneTask(task); + t.cost.consumed += event.reportedCost; + t.updatedAt = event.ts; + state.tasks[t.id] = t; +} + function applyAgentExited(state: SystemState, event: Extract<Event, { type: "AgentExited" }>, task: Task): void { const t = deepCloneTask(task); const costToConsume = Math.max(1, Math.floor(event.reportedCost)); @@ -787,12 +813,21 @@ function applyUnchecked(state: SystemState, event: Event): void { case "LeaseExpired": applyLeaseExpired(state, event, task); break; + case "LeaseReleased": + applyLeaseReleased(state, event, task); + break; + case "LeaseExtended": + applyLeaseExtended(state, event, task); + break; case "AgentStarted": applyAgentStarted(state, event, task); break; case "AgentExited": applyAgentExited(state, event, task); break; + case "CostReported": + applyCostReported(state, event, task); + break; case "PhaseTransition": applyPhaseTransition(state, event, task); break; diff --git a/core/types.ts b/core/types.ts index 083277a..521e0e7 100644 --- a/core/types.ts +++ b/core/types.ts @@ -225,6 +225,22 @@ export interface LeaseExpired extends BaseEvent { source: EventSource; } +export interface LeaseReleased extends BaseEvent { + type: "LeaseReleased"; + fenceToken: number; + reason: string; + phase: Phase; + workPerformed: boolean; + source: EventSource; +} + +export interface LeaseExtended extends BaseEvent { + type: "LeaseExtended"; + fenceToken: number; + leaseTimeout: Duration; + source: EventSource; +} + export interface AgentStarted extends BaseEvent { type: "AgentStarted"; fenceToken: number; @@ -239,6 +255,13 @@ export interface AgentExited extends BaseEvent { agentContext: AgentContext; } +export interface CostReported extends BaseEvent { + type: "CostReported"; + fenceToken: number; + reportedCost: number; + source: EventSource; +} + export type PhaseTransitionReason = | "decision_execute" | "decision_decompose" @@ -381,7 +404,7 @@ export interface TaskCompleted extends BaseEvent { export interface TaskFailed extends BaseEvent { type: "TaskFailed"; - reason: "budget_exhausted" | "cost_exhausted"; + reason: "budget_exhausted" | "cost_exhausted" | "review_rejected"; phase: Phase; summary: FailureSummary; } @@ -443,8 +466,11 @@ export type Event = | TaskCreated | LeaseGranted | LeaseExpired + | LeaseReleased + | LeaseExtended | AgentStarted | AgentExited + | CostReported | PhaseTransition | WaitRequested | WaitResolved diff --git a/core/validator.ts b/core/validator.ts index eaea5d0..0265f51 100644 --- a/core/validator.ts +++ b/core/validator.ts @@ -498,8 +498,8 @@ export function validateEvent(state: SystemState, event: Event): ValidationError } case "LeaseExpired": { - if (task.condition !== "leased") { - return mkError(event, "invalid_condition", "LeaseExpired requires leased condition."); + if (task.condition !== "leased" && task.condition !== "active") { + return mkError(event, "invalid_condition", "LeaseExpired requires leased or active condition."); } if (event.fenceToken !== task.currentFenceToken) { return mkError(event, "stale_fence_token", "LeaseExpired fence token mismatch."); @@ -507,6 +507,35 @@ export function validateEvent(state: SystemState, event: Event): ValidationError return null; } + case "LeaseReleased": { + if (task.condition !== "leased" && task.condition !== "active") { + return mkError(event, "invalid_condition", "LeaseReleased requires leased or active condition."); + } + if (event.fenceToken !== task.currentFenceToken) { + return mkError(event, "stale_fence_token", "LeaseReleased fence token mismatch."); + } + if (event.phase !== task.phase) { + return mkError(event, "phase_mismatch", "LeaseReleased.phase must match current task phase."); + } + if (!nonEmptyText(event.reason)) { + return mkError(event, "missing_release_reason", "LeaseReleased.reason must be non-empty."); + } + return null; + } + + case "LeaseExtended": { + if (task.condition !== "leased" && task.condition !== "active") { + return mkError(event, "invalid_condition", "LeaseExtended requires leased or active condition."); + } + if (event.fenceToken !== task.currentFenceToken) { + return mkError(event, "stale_fence_token", "LeaseExtended fence token mismatch."); + } + if (!isPositiveInt(event.leaseTimeout)) { + return mkError(event, "invalid_lease_timeout", "LeaseExtended.leaseTimeout must be a positive integer duration."); + } + return null; + } + case "AgentStarted": { if (task.condition !== "leased") { return mkError(event, "invalid_condition", "AgentStarted requires leased condition."); @@ -533,6 +562,19 @@ export function validateEvent(state: SystemState, event: Event): ValidationError return null; } + case "CostReported": { + if (task.condition !== "active" && task.condition !== "leased") { + return mkError(event, "invalid_condition", "CostReported requires leased or active condition."); + } + if (event.fenceToken !== task.currentFenceToken) { + return mkError(event, "stale_fence_token", "CostReported fence token mismatch."); + } + if (event.reportedCost < 0) { + return mkError(event, "invalid_cost", "CostReported.reportedCost must be >= 0."); + } + return null; + } + case "PhaseTransition": return validatePhaseTransition(event, task); diff --git a/middle/http.ts b/middle/http.ts index e65a87c..ccd0345 100644 --- a/middle/http.ts +++ b/middle/http.ts @@ -1,4 +1,7 @@ import * as http from "node:http"; +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; import type { Core } from "../core/index.js"; import type { AgentContext, @@ -21,6 +24,7 @@ import type { TaskCanceled, TaskCompleted, TaskCreated, + TaskFailed, TaskId, TaskReparented, TaskRevived, @@ -28,7 +32,10 @@ import type { } from "../core/types.js"; import { DEFAULT_ATTEMPT_BUDGETS } from "../core/types.js"; import type { Config } from "./config.js"; +import { buildPrompt } from "./prompt.js"; +import { commitJournal, createTaskBranch, getFailureSummaries, getJournalContent, taskBranch } from "./journal.js"; import { loadRegistry, validateMetadataRoles, type Registry } from "./registry.js"; +import { createWorktree, getWorktreePath } from "./worktree.js"; // --------------------------------------------------------------------------- // Priority helpers @@ -64,7 +71,7 @@ interface RouteResult { } interface StatusUpdateBody { - status: "review" | "done" | "blocked" | "pending" | "execute" | "decompose" | "cancel"; + status: "review" | "done" | "blocked" | "pending" | "execute" | "decompose" | "cancel" | "reject"; evidence?: string; blocker?: string; stateRef?: StateRef; @@ -77,6 +84,15 @@ interface StatusUpdateBody { }>; } +interface ClaimBody { + agentId?: string; + agent?: string; + leaseTimeout?: number; + contextBudget?: number; + modelId?: string; + source?: string; +} + interface TaskCreateBody { title: string; description: string; @@ -89,6 +105,9 @@ interface TaskCreateBody { skipAnalysis?: boolean; costBudget?: number; dependsOn?: string | string[]; + repo?: string; + baseBranch?: string; + base_branch?: string; } interface DecomposeBody { @@ -194,6 +213,110 @@ function defaultStateRef(): StateRef { return { branch: "main", commit: "0000000", parentCommit: "0000000" }; } +function truncateText(input: string, maxChars: number): string { + if (input.length <= maxChars) return input; + return `${input.slice(0, maxChars)}\n... (truncated, ${input.length - maxChars} chars omitted)`; +} + +function loadWorkspaceConventions(config: Config): string | null { + const agentsPath = path.join(config.workspaceDir, "AGENTS.md"); + try { + return fs.readFileSync(agentsPath, "utf-8"); + } catch { + return null; + } +} + +function ensureTaskWorkspaces(config: Config, task: Task): { + journalWorktree: string | null; + journalPath: string | null; + codeWorktree: string | null; + warnings: string[]; +} { + const warnings: string[] = []; + let journalWorktree: string | null = null; + let journalPath: string | null = null; + let codeWorktree: string | null = null; + + try { + const jBranch = taskBranch(task.id); + const jPath = getWorktreePath(config.worktreeBaseDir, task.id, "journal"); + createTaskBranch(config.journalRepoPath, task.id, task.parentId); + if (!fs.existsSync(jPath)) { + createWorktree(config.journalRepoPath, jPath, jBranch); + } + journalWorktree = jPath; + journalPath = `${path.join(jPath, "tasks", `T${task.id}`)}${path.sep}`; + } catch (err) { + warnings.push(`journal worktree setup failed: ${String(err)}`); + } + + const targetRepo = task.metadata["repo"] as string | undefined; + if (targetRepo) { + try { + const cPath = getWorktreePath(config.worktreeBaseDir, task.id, "code"); + const baseBranch = (task.metadata["base_branch"] as string | undefined) ?? "main"; + const codeBranch = `task/T${task.id}`; + if (!fs.existsSync(cPath)) { + createWorktree(targetRepo, cPath, codeBranch, baseBranch); + } + codeWorktree = cPath; + } catch (err) { + warnings.push(`code worktree setup failed: ${String(err)}`); + } + } + + return { journalWorktree, journalPath, codeWorktree, warnings }; +} + +function ensureJournalWorktreeForTask(config: Config, task: Task): { + journalWorktree: string; + taskDir: string; +} { + const workspaces = ensureTaskWorkspaces(config, task); + if (!workspaces.journalWorktree) { + throw new Error(`No journal worktree available for T${task.id}`); + } + const taskDir = path.join(workspaces.journalWorktree, "tasks", `T${task.id}`); + fs.mkdirSync(taskDir, { recursive: true }); + return { journalWorktree: workspaces.journalWorktree, taskDir }; +} + +function appendJournalEntry(config: Config, task: Task, entry: string): { + journalPath: string; + committed: boolean; +} { + const { journalWorktree, taskDir } = ensureJournalWorktreeForTask(config, task); + const journalFile = path.join(taskDir, "journal.md"); + + if (!fs.existsSync(journalFile)) { + fs.writeFileSync(journalFile, `# T${task.id} Journal\n`, "utf-8"); + } + + const trimmed = entry.trim(); + const stamp = new Date().toISOString(); + const block = `\n\n## ${stamp}\n${trimmed}\n`; + fs.appendFileSync(journalFile, block, "utf-8"); + commitJournal(journalWorktree, task.id, "journal update"); + + return { journalPath: `${taskDir}${path.sep}`, committed: true }; +} + +function writeJournalArtifact(config: Config, task: Task, name: string, content: string): { + journalPath: string; + filePath: string; +} { + const { journalWorktree, taskDir } = ensureJournalWorktreeForTask(config, task); + const safeName = path.basename(name); + if (!safeName || safeName === "." || safeName === "..") { + throw new Error("Invalid file name"); + } + const filePath = path.join(taskDir, safeName); + fs.writeFileSync(filePath, content, "utf-8"); + commitJournal(journalWorktree, task.id, `journal artifact ${safeName}`); + return { journalPath: `${taskDir}${path.sep}`, filePath }; +} + // --------------------------------------------------------------------------- // Route table builder // --------------------------------------------------------------------------- @@ -316,9 +439,15 @@ function buildRoutes(core: Core, config: Config, registry: Registry): RouteDef[] { method: "GET", pattern: "/tasks", handler: handleListTasks(core) }, { method: "GET", pattern: "/tasks/:id", handler: handleGetTask(core) }, { method: "GET", pattern: "/tasks/:id/events", handler: handleGetTaskEvents(core) }, + { method: "GET", pattern: "/tasks/:id/journal", handler: handleGetTaskJournal(core, config) }, + { method: "POST", pattern: "/tasks/:id/journal", handler: handleAppendTaskJournal(core, config) }, + { method: "POST", pattern: "/tasks/:id/journal/file", handler: handleWriteTaskJournalFile(core, config) }, + { method: "GET", pattern: "/tasks/:id/review/context", handler: handleReviewContext(core, config) }, + { method: "POST", pattern: "/tasks/:id/review/note", handler: handleReviewNote(core) }, { method: "GET", pattern: "/dispatchable", handler: handleDispatchable(core) }, { method: "POST", pattern: "/tasks", handler: handleCreateTask(core, config, registry) }, { method: "POST", pattern: "/tasks/:id/events", handler: handleSubmitEvent(core) }, + { method: "POST", pattern: "/tasks/:id/claim", handler: handleClaimTask(core, config) }, { method: "POST", pattern: "/tasks/:id/status", handler: handleStatusUpdate(core) }, { method: "POST", pattern: "/tasks/:id/reparent", handler: handleReparent(core) }, { method: "POST", pattern: "/tasks/:id/revive", handler: handleRevive(core) }, @@ -326,6 +455,7 @@ function buildRoutes(core: Core, config: Config, registry: Registry): RouteDef[] { method: "POST", pattern: "/tasks/:id/decompose/start", handler: handleDecomposeStart(core) }, { method: "POST", pattern: "/tasks/:id/decompose/add-child", handler: handleDecomposeAddChild(core) }, { method: "POST", pattern: "/tasks/:id/decompose/commit", handler: handleDecomposeCommit(core, config) }, + { method: "POST", pattern: "/tasks/:id/decompose/cancel", handler: handleDecomposeCancel(core) }, { method: "POST", pattern: "/tasks/:id/decompose", handler: handleDecompose(core, config) }, { method: "PATCH", pattern: "/tasks/:id/metadata", handler: handleMetadataUpdate(core, registry) }, { method: "GET", pattern: "/attention", handler: handleAttention(core) }, @@ -333,6 +463,163 @@ function buildRoutes(core: Core, config: Config, registry: Registry): RouteDef[] ]; } +function nowIso(): string { + return new Date().toISOString(); +} + +// POST /tasks/:id/claim — atomically lease+start+mark claim metadata +function handleClaimTask( + core: Core, + config: Config, +): RouteDef["handler"] { + return async (_req, params, body) => { + const taskId = params["id"]!; + const b = body as ClaimBody; + + const task = core.getTask(taskId); + if (!task) { + return { status: 404, body: { error: "not_found", message: `Task ${taskId} not found` } }; + } + if (task.terminal) { + return { status: 409, body: { error: "terminal", message: `Task ${taskId} is already ${task.terminal}` } }; + } + if (task.condition !== "ready") { + return { + status: 409, + body: { + error: "not_claimable", + message: `Task ${taskId} must be ready to claim, got ${task.phase}.${task.condition}`, + }, + }; + } + if (!task.phase) { + return { status: 409, body: { error: "terminal_task", message: `Task ${taskId} is terminal` } }; + } + + const agentId = typeof b.agentId === "string" && b.agentId.trim().length > 0 + ? b.agentId.trim() + : typeof b.agent === "string" && b.agent.trim().length > 0 + ? b.agent.trim() + : "agent-mcp"; + + const fenceToken = task.currentFenceToken + 1; + const sessionId = crypto.randomUUID(); + const leaseTimeout = Number.isFinite(b.leaseTimeout ?? NaN) + ? Number(b.leaseTimeout) + : config.leaseTimeoutMs; + const contextBudget = Number.isFinite(b.contextBudget ?? NaN) + ? Number(b.contextBudget) + : config.defaultContextBudget; + const modelId = typeof b.modelId === "string" && b.modelId.trim().length > 0 + ? b.modelId.trim() + : "self-directed"; + const claimSource = typeof b.source === "string" && b.source.trim().length > 0 ? b.source.trim() : "agent-mcp"; + + if (!Number.isInteger(leaseTimeout) || leaseTimeout <= 0) { + return { status: 400, body: { error: "invalid_lease_timeout", message: "leaseTimeout must be a positive integer." } }; + } + if (!Number.isInteger(contextBudget) || contextBudget <= 0) { + return { status: 400, body: { error: "invalid_context_budget", message: "contextBudget must be a positive integer." } }; + } + + const now = Date.now(); + const lg: LeaseGranted = { + type: "LeaseGranted", + taskId, + ts: now, + fenceToken, + agentId, + phase: task.phase, + leaseTimeout, + sessionId, + sessionType: "fresh", + contextBudget, + }; + let err = submitOrError(core, lg); + if (err) return err; + + const agentContext: AgentContext = { + sessionId, + agentId, + memoryRef: null, + contextTokens: null, + modelId, + }; + const started: AgentStarted = { + type: "AgentStarted", + taskId, + ts: now + 1, + fenceToken, + agentContext, + }; + err = submitOrError(core, started); + if (err) return err; + + const metadataUpdated: MetadataUpdated = { + type: "MetadataUpdated", + taskId, + ts: now + 2, + patch: { + claimedAt: nowIso(), + claimedBy: agentId, + claimSessionId: sessionId, + claimSessionKey: null, + claimSource, + }, + reason: "agent claimed task via MCP", + source: { type: "agent", id: agentId }, + }; + err = submitOrError(core, metadataUpdated); + if (err) return err; + + const updated = core.getTask(taskId); + if (!updated) { + return { status: 500, body: { error: "missing_task", message: `Task ${taskId} disappeared after claim` } }; + } + + const workspace = ensureTaskWorkspaces(config, updated); + const parentJournal = updated.parentId + ? getJournalContent(config.journalRepoPath, updated.parentId) + : null; + const siblingFailures = getFailureSummaries(config.journalRepoPath, updated.id).map((entry) => ({ + taskId: entry.taskId, + content: truncateText(entry.content, 500), + })); + const workspaceConventions = loadWorkspaceConventions(config); + + return { + status: 200, + body: { + claimed: true, + taskId, + sessionId, + fenceToken, + leaseTimeout, + task: { + id: updated.id, + title: updated.title, + description: updated.description, + phase: updated.phase, + priority: updated.metadata["priority"] ?? "medium", + assignee: updated.metadata["assignee"] ?? null, + reviewer: updated.metadata["reviewer"] ?? null, + consulted: updated.metadata["consulted"] ?? null, + parentId: updated.parentId, + failureSummaries: updated.failureSummaries, + reviewState: updated.reviewState, + }, + workspace, + parentJournal: parentJournal ? truncateText(parentJournal, 3000) : null, + siblingFailures, + workspaceConventions, + reviewContext: updated.phase === "review" ? buildPrompt(core, updated.id, "review", config) : null, + warnings: workspace.warnings, + guidance: "Start a fresh context (/new) unless this task is tightly coupled to your previous context.", + }, + }; + }; +} + // GET /health function handleHealth( core: Core, @@ -431,6 +718,172 @@ function handleGetTaskEvents( }; } +// GET /tasks/:id/journal +function handleGetTaskJournal( + core: Core, + config: Config, +): RouteDef["handler"] { + return async (_req, params) => { + const taskId = params["id"]!; + const task = core.getTask(taskId); + if (!task) { + return { status: 404, body: { error: "not_found", message: `Task ${taskId} not found` } }; + } + + const fromBranch = getJournalContent(config.journalRepoPath, taskId); + if (fromBranch !== null) { + return { status: 200, body: { taskId, content: fromBranch } }; + } + + const journalWorktree = getWorktreePath(config.worktreeBaseDir, taskId, "journal"); + const journalFile = path.join(journalWorktree, "tasks", `T${taskId}`, "journal.md"); + if (fs.existsSync(journalFile)) { + return { status: 200, body: { taskId, content: fs.readFileSync(journalFile, "utf-8") } }; + } + + return { status: 200, body: { taskId, content: "" } }; + }; +} + +// POST /tasks/:id/journal +function handleAppendTaskJournal( + core: Core, + config: Config, +): RouteDef["handler"] { + return async (_req, params, body) => { + const taskId = params["id"]!; + const task = core.getTask(taskId); + if (!task) { + return { status: 404, body: { error: "not_found", message: `Task ${taskId} not found` } }; + } + + const payload = body as { entry?: string }; + const entry = typeof payload.entry === "string" ? payload.entry.trim() : ""; + if (!entry) { + return { status: 400, body: { error: "missing_entry", message: "entry is required" } }; + } + + try { + const result = appendJournalEntry(config, task, entry); + return { + status: 200, + body: { + ok: true, + taskId, + journalPath: result.journalPath, + committed: result.committed, + }, + }; + } catch (err) { + return { status: 500, body: { error: "journal_write_failed", message: String(err) } }; + } + }; +} + +// POST /tasks/:id/journal/file +function handleWriteTaskJournalFile( + core: Core, + config: Config, +): RouteDef["handler"] { + return async (_req, params, body) => { + const taskId = params["id"]!; + const task = core.getTask(taskId); + if (!task) { + return { status: 404, body: { error: "not_found", message: `Task ${taskId} not found` } }; + } + + const payload = body as { name?: string; content?: string }; + const name = typeof payload.name === "string" ? payload.name.trim() : ""; + if (!name) { + return { status: 400, body: { error: "missing_name", message: "name is required" } }; + } + if (typeof payload.content !== "string") { + return { status: 400, body: { error: "missing_content", message: "content is required" } }; + } + + try { + const result = writeJournalArtifact(config, task, name, payload.content); + return { + status: 200, + body: { + ok: true, + taskId, + journalPath: result.journalPath, + filePath: result.filePath, + }, + }; + } catch (err) { + return { status: 500, body: { error: "journal_file_write_failed", message: String(err) } }; + } + }; +} + +// GET /tasks/:id/review/context +function handleReviewContext( + core: Core, + config: Config, +): RouteDef["handler"] { + return async (_req, params) => { + const taskId = params["id"]!; + const task = core.getTask(taskId); + if (!task) { + return { status: 404, body: { error: "not_found", message: `Task ${taskId} not found` } }; + } + + const notes = Array.isArray(task.metadata["review_notes"]) + ? task.metadata["review_notes"] + : []; + + return { + status: 200, + body: { + taskId, + phase: task.phase, + text: buildPrompt(core, taskId, "review", config), + notes, + }, + }; + }; +} + +// POST /tasks/:id/review/note +function handleReviewNote( + core: Core, +): RouteDef["handler"] { + return async (_req, params, body) => { + const taskId = params["id"]!; + const task = core.getTask(taskId); + if (!task) { + return { status: 404, body: { error: "not_found", message: `Task ${taskId} not found` } }; + } + + const payload = body as { note?: string }; + const note = typeof payload.note === "string" ? payload.note.trim() : ""; + if (!note) { + return { status: 400, body: { error: "missing_note", message: "note is required" } }; + } + + const existing = Array.isArray(task.metadata["review_notes"]) + ? task.metadata["review_notes"].map((entry) => String(entry)) + : []; + const notes = [...existing, note]; + + const event: MetadataUpdated = { + type: "MetadataUpdated", + taskId, + ts: Date.now(), + patch: { review_notes: notes }, + reason: "review note added via API", + source: { type: "middle", id: "daemon" }, + }; + + const err = submitOrError(core, event); + if (err) return err; + + return { status: 200, body: { ok: true, taskId, notes } }; + }; +} + // GET /dispatchable function handleDispatchable( core: Core, @@ -539,6 +992,8 @@ function handleCreateTask( consulted: b.consulted ?? null, priority: b.priority ?? "medium", informed: b.informed ?? null, + repo: b.repo ?? null, + base_branch: b.baseBranch ?? b.base_branch ?? null, createdBy: "http-api", createdAt: new Date().toISOString(), }, @@ -553,6 +1008,7 @@ function handleCreateTask( body: { taskId, status: "created", + phase: b.skipAnalysis ? "execution" : "analysis", assignee, priority: b.priority ?? "medium", parentId, @@ -610,6 +1066,9 @@ function handleStatusUpdate( case "done": return applyDoneTransition(core, task, fenceToken, ctx, now, b.evidence, b.stateRef); + case "reject": + return applyRejectTransition(core, task, fenceToken, ctx, now, b.evidence); + case "blocked": return applyBlockedTransition(core, task, now, b.blocker ?? b.evidence ?? "No reason provided"); @@ -920,12 +1379,30 @@ function applyDoneTransition( evidence?: string, stateRef?: StateRef, ): RouteResult { + if (task.phase === "execution" && task.condition === "active" && task.reviewConfig === null) { + const completed: TaskCompleted = { + type: "TaskCompleted", + taskId: task.id, + ts, + stateRef: stateRef ?? defaultStateRef(), + }; + const err = submitOrError(core, completed); + if (err) return err; + + notifyInformed(task, "✅ Done"); + + return { + status: 200, + body: { ok: true, taskId: task.id, transition: "execution.active → done" }, + }; + } + if (task.phase !== "review" || task.condition !== "active") { return { status: 409, body: { error: "invalid_state", - message: `Task must be in review.active for done, got ${task.phase}.${task.condition}`, + message: `Task must be in review.active for done (or execution.active without reviewer), got ${task.phase}.${task.condition}`, }, }; } @@ -977,6 +1454,77 @@ function applyDoneTransition( }; } +/** reject: review.active → failed */ +function applyRejectTransition( + core: Core, + task: Task, + fenceToken: number, + ctx: AgentContext, + ts: number, + evidence?: string, +): RouteResult { + if (task.phase !== "review" || task.condition !== "active") { + return { + status: 409, + body: { + error: "invalid_state", + message: `Task must be in review.active for reject, got ${task.phase}.${task.condition}`, + }, + }; + } + + const round = task.reviewState?.round ?? 1; + + const verdict: ReviewVerdictSubmitted = { + type: "ReviewVerdictSubmitted", + taskId: task.id, + ts, + fenceToken, + reviewer: ctx.agentId, + round, + verdict: "reject", + reasoning: evidence ?? "Rejected", + agentContext: ctx, + }; + let err = submitOrError(core, verdict); + if (err) return err; + + const policyMet: ReviewPolicyMet = { + type: "ReviewPolicyMet", + taskId: task.id, + ts: ts + 1, + outcome: "escalated", + summary: evidence ?? "Rejected by reviewer", + source: { type: "middle", id: "daemon" }, + }; + err = submitOrError(core, policyMet); + if (err) return err; + + const failed: TaskFailed = { + type: "TaskFailed", + taskId: task.id, + ts: ts + 2, + reason: "review_rejected", + phase: "review", + summary: { + childId: null, + approach: "review phase", + whatFailed: evidence ?? "Rejected by reviewer", + whatWasLearned: "Reviewer rejected the submission.", + artifactRef: null, + }, + }; + err = submitOrError(core, failed); + if (err) return err; + + notifyInformed(task, "❌ Rejected", evidence ?? "Rejected by reviewer"); + + return { + status: 200, + body: { ok: true, taskId: task.id, transition: "review.active → failed" }, + }; +} + /** blocked: any active state → TaskBlocked */ function applyBlockedTransition( core: Core, @@ -1568,6 +2116,29 @@ function handleDecomposeCommit( }; } +// POST /tasks/:id/decompose/cancel — discard pending decomposition session +function handleDecomposeCancel( + core: Core, +): RouteDef["handler"] { + return async (_req, params) => { + const taskId = params["id"]!; + const task = core.getTask(taskId); + if (!task) { + return { status: 404, body: { error: "not_found", message: `Task ${taskId} not found` } }; + } + + const existed = pendingDecompositions.delete(taskId); + return { + status: 200, + body: { + ok: true, + taskId, + canceled: existed, + }, + }; + }; +} + // Helpers for incremental decomposition export function cleanupPendingDecompositions(core: Core): void { for (const [taskId] of pendingDecompositions) { diff --git a/middle/test/http.test.ts b/middle/test/http.test.ts index 6dbbbb0..770c1a6 100644 --- a/middle/test/http.test.ts +++ b/middle/test/http.test.ts @@ -7,6 +7,7 @@ import * as path from "node:path"; import { OrchestrationCore } from "../../core/index.js"; import { createHttpServer } from "../http.js"; import { loadConfig, type Config } from "../config.js"; +import { initJournalRepo } from "../journal.js"; // --------------------------------------------------------------------------- // Test helpers @@ -15,6 +16,7 @@ import { loadConfig, type Config } from "../config.js"; let server: http.Server; let core: OrchestrationCore; let dbPath: string; +let tmpDir: string; let port: number; let config: Config; @@ -55,9 +57,25 @@ function request( } async function setup(): Promise<void> { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-test-")); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-test-")); dbPath = path.join(tmpDir, "test.db"); port = 18800 + Math.floor(Math.random() * 1000); + const journalRepoPath = path.join(tmpDir, "journal"); + const worktreeBaseDir = path.join(tmpDir, "worktrees"); + const workspaceDir = path.join(tmpDir, "workspace"); + const agentRegistry = path.join(tmpDir, "registry.json"); + + fs.mkdirSync(workspaceDir, { recursive: true }); + fs.mkdirSync(path.join(workspaceDir, "data"), { recursive: true }); + fs.writeFileSync(agentRegistry, JSON.stringify({ + agents: [ + { id: "coder", assignable: true, reviewer: true, consulted: true }, + { id: "analyst", assignable: true, reviewer: true, consulted: true }, + { id: "overseer", assignable: true, reviewer: true, consulted: true }, + { id: "hermes", assignable: true, reviewer: true, consulted: true }, + ], + }, null, 2)); + initJournalRepo(journalRepoPath); core = new OrchestrationCore({ dbPath, @@ -69,6 +87,10 @@ async function setup(): Promise<void> { ...loadConfig(), port, dbPath, + agentRegistry, + workspaceDir, + journalRepoPath, + worktreeBaseDir, runtimeFile: "", lifecycleFile: "", }; @@ -83,7 +105,7 @@ async function teardown(): Promise<void> { server.close(); core.close(); try { - fs.unlinkSync(dbPath); + fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { // Ignore } @@ -423,4 +445,97 @@ describe("HTTP API", () => { const finalTask = (taskRes.body as { task: { terminal: string | null } }).task; assert.equal(finalTask.terminal, "done"); }); + + test("claim creates workspace context and journal endpoints append/read", async () => { + await request("POST", "/tasks", { + title: "Claim workspace test", + description: "Verify claim returns workspace and journal routes work", + assignee: "coder", + }); + + const claimRes = await request("POST", "/tasks/1/claim", { + agentId: "coder", + source: "test", + }); + assert.equal(claimRes.status, 200); + const claimBody = claimRes.body as { workspace?: { journalPath?: string | null } }; + assert.ok(claimBody.workspace); + assert.ok(claimBody.workspace?.journalPath); + + const writeRes = await request("POST", "/tasks/1/journal", { + entry: "Starting implementation", + }); + assert.equal(writeRes.status, 200); + + const readRes = await request("GET", "/tasks/1/journal"); + assert.equal(readRes.status, 200); + const readBody = readRes.body as { content: string }; + assert.ok(readBody.content.includes("Starting implementation")); + }); + + test("status done completes execution task directly when no reviewer is configured", async () => { + await request("POST", "/tasks", { + title: "Direct completion", + description: "No reviewer task can complete from execution", + assignee: "coder", + skipAnalysis: true, + }); + + await request("POST", "/tasks/1/events", { + type: "LeaseGranted", + taskId: "1", + ts: Date.now(), + fenceToken: 1, + agentId: "coder", + phase: "execution", + leaseTimeout: 600000, + sessionId: "s-direct", + sessionType: "fresh", + contextBudget: 100, + }); + await request("POST", "/tasks/1/events", { + type: "AgentStarted", + taskId: "1", + ts: Date.now(), + fenceToken: 1, + agentContext: { + sessionId: "s-direct", + agentId: "coder", + memoryRef: null, + contextTokens: null, + modelId: "test", + }, + }); + + const doneRes = await request("POST", "/tasks/1/status", { + status: "done", + evidence: "Execution work complete", + }); + assert.equal(doneRes.status, 200); + + const taskRes = await request("GET", "/tasks/1"); + const task = (taskRes.body as { task: { terminal: string | null } }).task; + assert.equal(task.terminal, "done"); + }); + + test("decompose cancel clears pending incremental session", async () => { + await request("POST", "/tasks", { + title: "Cancel decompose", + description: "Check decompose cancel endpoint", + assignee: "coder", + }); + + await request("POST", "/tasks/1/claim", { + agentId: "coder", + source: "test", + }); + + const startRes = await request("POST", "/tasks/1/decompose/start"); + assert.equal(startRes.status, 200); + + const cancelRes = await request("POST", "/tasks/1/decompose/cancel"); + assert.equal(cancelRes.status, 200); + const cancelBody = cancelRes.body as { canceled: boolean }; + assert.equal(cancelBody.canceled, true); + }); }); diff --git a/package.json b/package.json index 72d53e0..4838ef0 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "taskcore", "version": "0.1.0", "type": "module", + "bin": { + "task": "dist/core/cli/task.js" + }, "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", @@ -9,7 +12,8 @@ "test:middle": "tsx --test middle/test/*.test.ts", "test:all": "tsx --test core/test/*.test.ts middle/test/*.test.ts", "daemon": "node --import tsx middle/daemon.ts", - "migrate": "node --import tsx middle/migrate.ts" + "migrate": "node --import tsx middle/migrate.ts", + "task": "node --import tsx core/cli/task.ts" }, "dependencies": { "better-sqlite3": "^11.10.0" From da199fb9edf56a4d12ed36136b75c58c4215c9ee Mon Sep 17 00:00:00 2001 From: krandder <azsantos.k@gmail.com> Date: Thu, 5 Mar 2026 17:21:14 -0300 Subject: [PATCH 4/5] Address review follow-ups --- .github/workflows/formal-verification.yml | 21 ++++++++--- core/cli/task.ts | 44 ++++++++++++++++++++--- middle/http.ts | 1 + 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/.github/workflows/formal-verification.yml b/.github/workflows/formal-verification.yml index 9db0bec..2823b77 100644 --- a/.github/workflows/formal-verification.yml +++ b/.github/workflows/formal-verification.yml @@ -12,6 +12,9 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + env: + TLA_TOOLS_VERSION: "1.8.0" + ALLOY_VERSION: "6.2.0" steps: - name: Checkout uses: actions/checkout@v4 @@ -22,14 +25,24 @@ jobs: distribution: temurin java-version: "17" + - name: Cache formal tools + uses: actions/cache@v4 + with: + path: .ci-tools + key: ${{ runner.os }}-formal-tools-${{ env.TLA_TOOLS_VERSION }}-${{ env.ALLOY_VERSION }} + - name: Download formal tools shell: bash run: | mkdir -p .ci-tools - curl -fsSL -o .ci-tools/tla2tools.jar \ - https://github.com/tlaplus/tlaplus/releases/download/v1.8.0/tla2tools.jar - curl -fsSL -o .ci-tools/alloy.jar \ - https://repo1.maven.org/maven2/org/alloytools/org.alloytools.alloy.dist/6.2.0/org.alloytools.alloy.dist-6.2.0.jar + if [ ! -f .ci-tools/tla2tools.jar ]; then + curl -fsSL -o .ci-tools/tla2tools.jar \ + https://github.com/tlaplus/tlaplus/releases/download/v${TLA_TOOLS_VERSION}/tla2tools.jar + fi + if [ ! -f .ci-tools/alloy.jar ]; then + curl -fsSL -o .ci-tools/alloy.jar \ + https://repo1.maven.org/maven2/org/alloytools/org.alloytools.alloy.dist/${ALLOY_VERSION}/org.alloytools.alloy.dist-${ALLOY_VERSION}.jar + fi - name: Run TLA+ specifications shell: bash diff --git a/core/cli/task.ts b/core/cli/task.ts index 728c759..bc992dc 100644 --- a/core/cli/task.ts +++ b/core/cli/task.ts @@ -1137,16 +1137,50 @@ async function cmdDecompose(argv: string[], jsonMode: boolean): Promise<void> { switch (sub) { case "start": { + const task = await getTask(taskId); const response = await apiRequest("POST", `/tasks/${taskId}/decompose/start`, {}); if (jsonMode) { - process.stdout.write(JSON.stringify(response, null, 2) + "\n"); + process.stdout.write(JSON.stringify({ task, ...response }, null, 2) + "\n"); return; } - process.stdout.write(`--- Decomposition: T${taskId} ---\n`); - process.stdout.write(`Budget remaining: ${formatMoney(response["budgetRemaining"])}\n`); - process.stdout.write(`Children so far: ${String(response["childrenSoFar"] ?? 0)}\n\n`); - process.stdout.write(`task decompose add \"Child title\" --desc \"What this child should do\" --cost 10\n`); + process.stdout.write(`--- Decomposition: T${taskId} — ${getString(task, "title", "(untitled)")} ---\n\n`); + process.stdout.write("## Task Description\n"); + process.stdout.write(getString(task, "description", "(none)") + "\n"); + + process.stdout.write("\n## Budget\n"); + process.stdout.write(` Remaining: ${formatMoney(response["budgetRemaining"])}\n`); + process.stdout.write(" You must allocate cost to each child from this budget.\n"); + + const approaches = asArray<unknown>(task["approachHistory"]) + .map((entry) => asRecord(entry)) + .filter((entry): entry is Record<string, unknown> => entry !== null); + process.stdout.write("\n## Previous Decompositions\n"); + if (approaches.length === 0) { + process.stdout.write(" (none — first attempt)\n"); + } else { + for (const approach of approaches) { + process.stdout.write( + ` v${String(approach["version"] ?? "?")}: ${getString(approach, "description", "decomposition")} — ${getString(approach, "outcome", "unknown")}\n`, + ); + const failureSummary = getString(approach, "failureSummary", ""); + if (failureSummary) { + process.stdout.write(` Failed: ${failureSummary}\n`); + } + } + } + + process.stdout.write("\n## Guidelines\n"); + process.stdout.write(" - Each child should be completable by one agent in one session\n"); + process.stdout.write(" - Children should be as independent as possible\n"); + process.stdout.write(" - Use --depends-on when order matters (0-indexed sibling position)\n"); + process.stdout.write(" - Leave assignee blank unless a specific agent is needed\n"); + + process.stdout.write("\n## Next Step\n"); + process.stdout.write(" Add your first child:\n\n"); + process.stdout.write(" task decompose add \"Child title\" \\\n"); + process.stdout.write(" --desc \"What this child should do\" \\\n"); + process.stdout.write(" --cost 10\n"); return; } diff --git a/middle/http.ts b/middle/http.ts index ccd0345..55f8b73 100644 --- a/middle/http.ts +++ b/middle/http.ts @@ -577,6 +577,7 @@ function handleClaimTask( return { status: 500, body: { error: "missing_task", message: `Task ${taskId} disappeared after claim` } }; } + // The daemon owns worktree creation so the CLI can stay a pure HTTP client. const workspace = ensureTaskWorkspaces(config, updated); const parentJournal = updated.parentId ? getJournalContent(config.journalRepoPath, updated.parentId) From d7bb234ec3158d2681e9d57ccca400299a961827 Mon Sep 17 00:00:00 2001 From: krandder <azsantos.k@gmail.com> Date: Thu, 5 Mar 2026 18:51:39 -0300 Subject: [PATCH 5/5] Remove legacy bridge cutover leftovers --- README.md | 24 ++- core/docs/task-cli-spec.md | 26 ++- middle/delegate-bridge.py | 116 ----------- middle/dispatcher.ts | 55 ++++- middle/http.ts | 24 +-- middle/mcp-bridge.ts | 382 ----------------------------------- middle/prompt.ts | 69 ++++--- middle/task-update-bridge.py | 105 ---------- middle/test/prompt.test.ts | 119 +++++++++++ task | 10 + 10 files changed, 253 insertions(+), 677 deletions(-) delete mode 100644 middle/delegate-bridge.py delete mode 100644 middle/mcp-bridge.ts delete mode 100644 middle/task-update-bridge.py create mode 100644 middle/test/prompt.test.ts create mode 100755 task diff --git a/README.md b/README.md index f8de9db..ce09452 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ taskcore takes a different approach: **tasks are event streams, not records.** E - **Invariant checking.** After every event, the system verifies 20+ structural invariants. If a bug would corrupt state, it's caught immediately — not discovered days later. - **Safe concurrency.** Fence tokens prevent stale agents from making conflicting updates. The core rejects events from agents that no longer hold the lease. -The core is a pure TypeScript library with zero side effects. It doesn't spawn processes, make HTTP calls, or touch the filesystem (except SQLite). The middle layer adds the messy real-world stuff: process spawning, HTTP APIs, agent bridges. +The core is a pure TypeScript library with zero side effects. It doesn't spawn processes, make HTTP calls, or touch the filesystem (except SQLite). The runtime layer adds the messy real-world stuff: process spawning, HTTP APIs, worktrees, journals, and the `task` CLI. ## Architecture @@ -25,12 +25,12 @@ The core is a pure TypeScript library with zero side effects. It doesn't spawn p │ Pure state │ Daemon │ │ machine │ HTTP API │ │ (frozen) │ Dispatcher │ - │ │ Bridges │ + │ │ Worktrees │ │ 45 tests │ 9 tests │ └──────────────┴──────────────┘ ▲ │ │ ▼ - Event log Agent processes + Event log task CLI / agents (SQLite) (openclaw agent) ``` @@ -56,11 +56,16 @@ The bridge between the pure core and the real world. - **dispatcher.ts** — Priority-sorted dispatch, agent spawn, exit handling with exponential backoff - **analysis.ts** — Auto-analysis: tasks with an assignee skip straight to execution - **prompt.ts** — Prompt builder for work and review modes -- **mcp-bridge.ts** — MCP stdio server (JSON-RPC 2.0) for agents that use MCP tools -- **task-update-bridge.py** — CLI for agents to report status -- **delegate-bridge.py** — CLI for agents to create subtasks +- **journal.ts** — Task journal branches, merges, and failure-summary helpers +- **worktree.ts** — Journal/code worktree lifecycle +- **state-export.ts** — Dashboard compatibility export - **migrate.ts** — One-shot migration from legacy `tasks.json` format +### CLI (`core/cli/` + repo root `task`) + +- **task.ts** — Agent/user CLI over the daemon HTTP API; manages `.task` context, journal, review, and decomposition workflows +- **task** — Repo-local launcher that runs the built CLI or falls back to `tsx` in a source checkout + ## Task Lifecycle Every task follows a phase-based lifecycle: @@ -112,6 +117,7 @@ Default: `127.0.0.1:18800` | GET | `/health` | Health check + stats | | GET | `/tasks` | List tasks (filters: `?phase=`, `?condition=`, `?terminal=`, `?full=true`) | | GET | `/tasks/:id` | Get single task (full detail) | +| POST | `/tasks/:id/claim` | Atomically lease and start a task for an agent | | GET | `/dispatchable` | List tasks ready for dispatch | | POST | `/tasks` | Create task | | POST | `/tasks/:id/events` | Submit raw event (fenced) | @@ -151,6 +157,9 @@ curl -X POST http://127.0.0.1:18800/tasks \ # Check task state curl http://127.0.0.1:18800/tasks/1 + +# Explore the CLI from this checkout +./task --help ``` ### Environment Variables @@ -163,6 +172,7 @@ curl http://127.0.0.1:18800/tasks/1 | `WORKSPACE_DIR` | `~/.openclaw/workspace` | Workspace root | | `MAX_CONCURRENT` | 1 | Max concurrent agent dispatches | | `TICK_INTERVAL_MS` | 2000 | Core tick interval (auto-events) | +| `DISPATCHER_ENABLED` | true | Enable built-in dispatcher loop (`true`/`false`) | | `DISPATCH_INTERVAL_MS` | 10000 | Dispatch loop interval | | `LEASE_TIMEOUT_MS` | 600000 | Default agent lease timeout | | `AGENT_TIMEOUT_MS` | 600000 | Max agent runtime before SIGKILL | @@ -189,7 +199,7 @@ npm run migrate -- --tasks-file /path/to/tasks.json --db /path/to/taskcore.db ### Near Term - [ ] Worktree isolation for agents (each agent gets a git worktree) -- [ ] Structured decomposition via core events (currently flat `delegate`) +- [ ] Richer decomposition strategy/history as first-class core events - [ ] Dashboard direct integration (query taskcore API instead of exporter) - [ ] Snapshot pruning (keep only last N snapshots) diff --git a/core/docs/task-cli-spec.md b/core/docs/task-cli-spec.md index 95d6c57..2ee43c8 100644 --- a/core/docs/task-cli-spec.md +++ b/core/docs/task-cli-spec.md @@ -1,6 +1,6 @@ # `task` CLI Specification -> Status: Draft v1 — 2026-03-05 +> Status: Implemented v1 — 2026-03-05 ## 1. Design Principles @@ -21,8 +21,8 @@ the `.task` context file at the worktree root. The agent works inside the worktree. Everything is scoped. -5. **Replaces MCP entirely.** This CLI is the sole interface between agents and - taskcore. The MCP server (`openclaw-mcp-server.mjs`) is retired. +5. **Retires the legacy bridge layer.** This CLI is the sole interface between + agents and taskcore. 6. **Human-readable output first.** Agents consume the same text humans would read. `--json` flag reserved for future machine parsing. @@ -171,7 +171,7 @@ These require `$TASKCORE_AGENT_ID`. #### `task create` -Create a new task (replaces `delegate` MCP tool). +Create a new task (replaces the legacy `delegate` flow). ``` task create <title> --description <desc> @@ -817,6 +817,7 @@ TypeScript, lives in the taskcore repo at `core/cli/`. Shares types with core. Compiles to a single executable via the existing build pipeline. Installed as `task` (symlink or bin entry in package.json). +In a source checkout, `./task` is a repo-local launcher for the same CLI. ### Communication @@ -843,13 +844,11 @@ cd "$WORKTREE" - 2: API error (daemon unreachable, task not found, invalid state transition) - 3: auth error (no TASKCORE_AGENT_ID when required) -### MCP Cutover Plan +### Cutover Status -1. Build CLI with feature parity to MCP tools -2. Update dispatcher to use `task` CLI in agent prompts instead of curl examples -3. Update agent CLAUDE.md / AGENTS.md to reference `task` commands -4. Remove MCP server from `.mcp.json` -5. Delete `openclaw-mcp-server.mjs` +1. `task` has feature parity with the retired bridge commands. +2. Dispatcher prompts use `task` commands instead of raw HTTP examples. +3. Legacy bridge entrypoints are removed from the runtime. ## 7. Open Questions @@ -862,7 +861,6 @@ cd "$WORKTREE" 3. **Offline mode**: Should the CLI cache task data for when the daemon is unreachable? (Probably not for v1.) -4. **Dispatcher integration**: The dispatcher currently spawns `openclaw agent`. - Should it switch to having agents self-serve via `task claim` + `task submit`? - Or keep the dispatcher for auto-dispatch and have the CLI for self-directed - work? +4. **Dispatcher integration**: The dispatcher currently auto-dispatches work. + Should it stay opinionated, or shift toward more self-directed claiming via + `task claim`? diff --git a/middle/delegate-bridge.py b/middle/delegate-bridge.py deleted file mode 100644 index 41b290e..0000000 --- a/middle/delegate-bridge.py +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env python3 -""" -delegate-bridge.py — Drop-in replacement for delegate.py that creates tasks -via the taskcore daemon HTTP API. - -Same CLI interface as the original: - python3 delegate-bridge.py --title T --task D --reviewer R [--assignee A] [--priority P] - -Environment: - ORCHESTRATOR_PORT (default 18800) -""" - -import argparse -import json -import os -import sys -import urllib.request -import urllib.error - -PORT = int(os.environ.get("ORCHESTRATOR_PORT", "18800")) -BASE = f"http://127.0.0.1:{PORT}" - -PRIORITY_CHOICES = ["backlog", "low", "medium", "high", "critical"] -AGENT_CHOICES = [ - "coder", "analyst", "coder-lite", "hermes", "ceo", "orchestrator", -] -REVIEWER_CHOICES = AGENT_CHOICES + ["kelvin", "arthur"] - - -def post(path: str, body: dict) -> dict: - url = f"{BASE}{path}" - data = json.dumps(body).encode("utf-8") - req = urllib.request.Request( - url, - data=data, - headers={"Content-Type": "application/json"}, - method="POST", - ) - try: - with urllib.request.urlopen(req) as resp: - return json.loads(resp.read().decode("utf-8")) - except urllib.error.HTTPError as e: - body_text = e.read().decode("utf-8", errors="replace") - try: - return json.loads(body_text) - except json.JSONDecodeError: - return {"error": f"HTTP {e.code}", "message": body_text} - - -def main(): - parser = argparse.ArgumentParser( - description="Create a task via taskcore daemon" - ) - parser.add_argument("--title", required=True, help="Short task title") - parser.add_argument("--task", required=True, help="Detailed task description") - parser.add_argument( - "--assignee", - default=None, - choices=AGENT_CHOICES, - help="Agent to assign", - ) - parser.add_argument( - "--priority", - default="medium", - choices=PRIORITY_CHOICES, - help="Task priority", - ) - parser.add_argument( - "--reviewer", - required=True, - choices=REVIEWER_CHOICES, - help="Reviewer agent or role", - ) - parser.add_argument( - "--consulted", - default=None, - choices=["analyst", "hermes", "ceo"], - help="Agent to consult if blocked", - ) - parser.add_argument( - "--parent-task-id", - default=None, - type=str, - help="Parent task ID for linking", - ) - parser.add_argument( - "--informed", - default=None, - nargs="*", - help="Notification targets (telegram:id, session, etc.)", - ) - - args = parser.parse_args() - - body = { - "title": args.title, - "description": args.task, - "priority": args.priority, - "reviewer": args.reviewer, - } - - if args.assignee: - body["assignee"] = args.assignee - if args.consulted: - body["consulted"] = args.consulted - if args.parent_task_id: - body["parentId"] = args.parent_task_id - if args.informed: - body["informed"] = args.informed - - result = post("/tasks", body) - print(json.dumps(result, indent=2)) - - -if __name__ == "__main__": - main() diff --git a/middle/dispatcher.ts b/middle/dispatcher.ts index cc03e30..2236292 100644 --- a/middle/dispatcher.ts +++ b/middle/dispatcher.ts @@ -2,6 +2,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import * as crypto from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; +import { fileURLToPath } from "node:url"; import type { Core } from "../core/index.js"; import type { AgentContext, @@ -381,6 +382,16 @@ interface DetectedStatus { evidence: string; } +function taskcoreRootDir(): string { + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const parentDir = path.dirname(moduleDir); + return path.basename(parentDir) === "dist" ? path.dirname(parentDir) : parentDir; +} + +function taskCliPathEnv(): string { + return [taskcoreRootDir(), process.env["PATH"] ?? ""].filter(Boolean).join(path.delimiter); +} + /** * Parse agent stdout for review verdicts. * @@ -399,7 +410,7 @@ function detectStatusFromOutput( phase: "analysis" | "execution" | "review", _task: Task, ): DetectedStatus | null { - // Only auto-detect for review phase — execution agents use task_update.py shim + // Only auto-detect for review phase — other phases report explicitly via task CLI. if (phase !== "review") return null; // Extract the text payload from agent JSON output @@ -431,7 +442,7 @@ function detectStatusFromOutput( } } - // Strip fenced code blocks to avoid matching on curl templates / JSON payloads + // Strip fenced code blocks to avoid matching on command templates / JSON payloads // that the agent is quoting in its reasoning (not actual verdicts). const stripped = lower.replace(/```[\s\S]*?```/g, " [code-block] "); @@ -796,7 +807,9 @@ export function createDispatcher(core: Core, config: Config): Dispatcher { const spawnEnv: Record<string, string | undefined> = { ...process.env, TASK_ID: task.id, + TASKCORE_AGENT_ID: agentId, ORCHESTRATOR_PORT: String(config.port), + PATH: taskCliPathEnv(), }; if (journalWorktreePath) { spawnEnv["JOURNAL_PATH"] = `${journalWorktreePath}/tasks/T${task.id}/`; @@ -1054,7 +1067,7 @@ export function createDispatcher(core: Core, config: Config): Dispatcher { "warning", "retry-exhausted", `T${run.taskId} ${run.agentId} exhausted (budget depleted)`, - `Task "${taskAfterRetry.title}" ran out of budget. Needs manual budget increase via POST /tasks/${run.taskId}/budget.`, + `Task "${taskAfterRetry.title}" ran out of budget. Increase it with \`task budget ${run.taskId} --cost N\`.`, { taskId: run.taskId, agentId: run.agentId, phase: run.phase }, [run.agentId, "exhausted"], ); @@ -1140,15 +1153,37 @@ export function createDispatcher(core: Core, config: Config): Dispatcher { * This doesn't burn a retry attempt; if the nudge also fails, the normal retry flow kicks in. */ function spawnStatusNudge(originalRun: ActiveRun, task: Task): void { - const statusType = task.phase === "review" ? "done" : "review"; + const nudgeCommands = task.phase === "review" + ? [ + `task review approve "Brief summary of why this passes"`, + `task review request-changes "Specific feedback for the assignee"`, + `task review reject "Fundamental issue requiring re-analysis"`, + ] + : task.phase === "analysis" + ? [ + "task decide execute", + "task decompose start", + `task block "Why this cannot proceed"`, + ] + : task.phase === "decomposition" + ? [ + "task decompose start", + `task block "Why decomposition is blocked"`, + ] + : [ + task.reviewConfig === null + ? `task complete "Brief summary of what you finished"` + : `task submit "Brief summary of what you finished"`, + `task block "What is blocking you"`, + ]; + const nudgeMessage = [ - `Run this exact command now. Do not do anything else — just run this command:`, + `Report your status now using the task CLI. Do not do more work first.`, + `TASK_ID and TASKCORE_AGENT_ID are already set for this session.`, ``, - `curl -s -X POST http://127.0.0.1:${config.port}/tasks/${task.id}/status \\`, - ` -H 'Content-Type: application/json' \\`, - ` -d '{"status": "${statusType}", "evidence": "Brief summary of what you did"}'`, + `Run the command that matches the state you are already in:`, ``, - `Replace the evidence text with a short summary of what you actually did, then run it.`, + ...nudgeCommands, ].join("\n"); const args = [ @@ -1167,7 +1202,9 @@ export function createDispatcher(core: Core, config: Config): Dispatcher { env: { ...process.env, TASK_ID: task.id, + TASKCORE_AGENT_ID: originalRun.agentId, ORCHESTRATOR_PORT: String(config.port), + PATH: taskCliPathEnv(), }, }); diff --git a/middle/http.ts b/middle/http.ts index 55f8b73..bc6b311 100644 --- a/middle/http.ts +++ b/middle/http.ts @@ -500,7 +500,7 @@ function handleClaimTask( ? b.agentId.trim() : typeof b.agent === "string" && b.agent.trim().length > 0 ? b.agent.trim() - : "agent-mcp"; + : "task-client"; const fenceToken = task.currentFenceToken + 1; const sessionId = crypto.randomUUID(); @@ -513,7 +513,7 @@ function handleClaimTask( const modelId = typeof b.modelId === "string" && b.modelId.trim().length > 0 ? b.modelId.trim() : "self-directed"; - const claimSource = typeof b.source === "string" && b.source.trim().length > 0 ? b.source.trim() : "agent-mcp"; + const claimSource = typeof b.source === "string" && b.source.trim().length > 0 ? b.source.trim() : "task-api"; if (!Number.isInteger(leaseTimeout) || leaseTimeout <= 0) { return { status: 400, body: { error: "invalid_lease_timeout", message: "leaseTimeout must be a positive integer." } }; @@ -566,7 +566,7 @@ function handleClaimTask( claimSessionKey: null, claimSource, }, - reason: "agent claimed task via MCP", + reason: "agent claimed task via task API", source: { type: "agent", id: agentId }, }; err = submitOrError(core, metadataUpdated); @@ -1825,7 +1825,7 @@ function handleDecomposeAddChild( if (!pending) { return { status: 409, - body: { error: "no_session", message: `No pending decomposition session for T${taskId}. Call POST /tasks/${taskId}/decompose/start first.` }, + body: { error: "no_session", message: `No pending decomposition session for T${taskId}. Call task decompose start first.` }, }; } @@ -1910,8 +1910,8 @@ function handleDecomposeAddChild( budgetRemaining: costRemaining - newTotal, children: childrenSummary, guidance: `Child ${childIndex} added ("${b.title.trim()}"). Add another child or commit the decomposition:\n` + - ` curl -s -X POST http://127.0.0.1:18800/tasks/${taskId}/decompose/add-child -H 'Content-Type: application/json' -d '{...}'\n` + - ` curl -s -X POST http://127.0.0.1:18800/tasks/${taskId}/decompose/commit -H 'Content-Type: application/json' -d '{"approach": "your strategy"}'`, + ` task decompose add "Next title" --desc "What this child should do" --cost 10\n` + + ` task decompose commit "your strategy"`, }, }; }; @@ -1929,7 +1929,7 @@ function handleDecomposeCommit( if (!pending) { return { status: 409, - body: { error: "no_session", message: `No pending decomposition session for T${taskId}. Call POST /tasks/${taskId}/decompose/start first.` }, + body: { error: "no_session", message: `No pending decomposition session for T${taskId}. Call task decompose start first.` }, }; } @@ -2160,14 +2160,12 @@ function computeCostRemaining(task: Task): number { function buildAddChildGuidance(taskId: string): string { return `Add children one at a time:\n` + - ` curl -s -X POST http://127.0.0.1:18800/tasks/${taskId}/decompose/add-child \\\n` + - ` -H 'Content-Type: application/json' \\\n` + - ` -d '{"title": "...", "description": "...", "costAllocation": 10}'\n\n` + + ` task decompose add "Child title" \\\n` + + ` --desc "What this child should do" \\\n` + + ` --cost 10\n\n` + `Optional fields: assignee, reviewer, dependsOnSiblings (array of sibling indices, 0-based), skipAnalysis (default false — only set true for trivial tasks).\n` + `When done adding children, commit:\n` + - ` curl -s -X POST http://127.0.0.1:18800/tasks/${taskId}/decompose/commit \\\n` + - ` -H 'Content-Type: application/json' \\\n` + - ` -d '{"approach": "brief description of decomposition strategy"}'`; + ` task decompose commit "brief description of decomposition strategy"`; } // --------------------------------------------------------------------------- diff --git a/middle/mcp-bridge.ts b/middle/mcp-bridge.ts deleted file mode 100644 index 162c0fb..0000000 --- a/middle/mcp-bridge.ts +++ /dev/null @@ -1,382 +0,0 @@ -#!/usr/bin/env node -/** - * MCP Bridge — stdio JSON-RPC 2.0 server that forwards tool calls to the - * taskcore daemon HTTP API. - * - * Same tool interface as openclaw-mcp-server.mjs so agents can use it as a - * drop-in replacement. - * - * Usage: - * node --import tsx middle/mcp-bridge.ts - * - * Environment: - * ORCHESTRATOR_PORT (default 18800) - * TASK_ID (optional, pre-set task context for update_status) - */ - -import * as http from "node:http"; -import * as readline from "node:readline"; - -const PORT = parseInt(process.env["ORCHESTRATOR_PORT"] ?? "18800", 10); -const BASE = `http://127.0.0.1:${PORT}`; -const CONTEXT_TASK_ID = process.env["TASK_ID"] ?? null; - -// --------------------------------------------------------------------------- -// HTTP client helper -// --------------------------------------------------------------------------- - -function httpRequest( - method: string, - urlPath: string, - body?: unknown, -): Promise<{ status: number; body: unknown }> { - return new Promise((resolve, reject) => { - const url = new URL(urlPath, BASE); - const data = body ? JSON.stringify(body) : undefined; - - const req = http.request( - { - hostname: url.hostname, - port: url.port, - path: url.pathname + url.search, - method, - headers: data - ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) } - : {}, - }, - (res) => { - const chunks: Buffer[] = []; - res.on("data", (c: Buffer) => chunks.push(c)); - res.on("end", () => { - const raw = Buffer.concat(chunks).toString("utf-8"); - try { - resolve({ status: res.statusCode ?? 500, body: JSON.parse(raw) }); - } catch { - resolve({ status: res.statusCode ?? 500, body: raw }); - } - }); - }, - ); - req.on("error", reject); - if (data) req.write(data); - req.end(); - }); -} - -// --------------------------------------------------------------------------- -// Tool definitions -// --------------------------------------------------------------------------- - -const TOOLS = [ - { - name: "delegate", - description: - "Create a tracked task and optionally assign it for automatic dispatch.", - inputSchema: { - type: "object", - properties: { - title: { type: "string", description: "Short title for the task" }, - task: { type: "string", description: "Detailed task description" }, - assignee: { - type: "string", - description: "Agent to assign (coder, analyst, coder-lite, overseer, etc.)", - }, - priority: { - type: "string", - enum: ["backlog", "low", "medium", "high", "critical"], - default: "medium", - }, - reviewer: { type: "string", description: "Agent or role that reviews work" }, - consulted: { type: "string", description: "Agent to ask if blocked" }, - parentTaskId: { - type: "string", - description: "Optional parent task ID", - }, - dependsOn: { - type: "array", - items: { type: "string" }, - description: - "Optional task IDs that this task depends on. The new task will start in 'waiting' condition until all dependencies complete.", - }, - }, - required: ["title", "task"], - }, - }, - { - name: "update_status", - description: "Update the status of a task (report work complete, blocked, etc.)", - inputSchema: { - type: "object", - properties: { - taskId: { - type: "string", - description: "Task ID to update (defaults to TASK_ID env var)", - }, - status: { - type: "string", - enum: ["review", "done", "blocked", "pending", "execute"], - description: "New status", - }, - evidence: { type: "string", description: "Evidence or notes" }, - blocker: { type: "string", description: "Blocker description (if blocked)" }, - }, - required: ["status"], - }, - }, - { - name: "update_metadata", - description: "Update metadata on an existing task (priority, assignee, reviewer, etc.)", - inputSchema: { - type: "object", - properties: { - taskId: { type: "string", description: "Task ID to update" }, - priority: { - type: "string", - enum: ["backlog", "low", "medium", "high", "critical"], - description: "New priority level", - }, - assignee: { type: "string", description: "New agent assignee" }, - reviewer: { type: "string", description: "New reviewer" }, - consulted: { type: "string", description: "New consulted agent" }, - informed: { - type: "array", - items: { type: "string" }, - description: "New notification targets", - }, - reason: { type: "string", description: "Reason for the change" }, - }, - required: ["taskId"], - }, - }, - { - name: "report_incident", - description: "Report an incident (error, timeout, unexpected behavior, etc.)", - inputSchema: { - type: "object", - properties: { - severity: { - type: "string", - enum: ["critical", "error", "warning", "info"], - }, - category: { type: "string", description: "Incident category" }, - summary: { type: "string", description: "Short description" }, - detail: { type: "string", description: "Extended description" }, - tags: { type: "array", items: { type: "string" } }, - }, - required: ["severity", "category", "summary"], - }, - }, -]; - -// --------------------------------------------------------------------------- -// Tool handlers -// --------------------------------------------------------------------------- - -async function handleDelegate(args: Record<string, unknown>): Promise<unknown> { - const body: Record<string, unknown> = { - title: args["title"] as string, - description: args["task"] as string, - assignee: args["assignee"] ?? null, - reviewer: args["reviewer"] ?? null, - consulted: args["consulted"] ?? null, - priority: args["priority"] ?? "medium", - parentId: args["parentTaskId"] ? String(args["parentTaskId"]) : null, - }; - - if (args["dependsOn"]) { - body["dependsOn"] = args["dependsOn"]; - } - - const res = await httpRequest("POST", "/tasks", body); - return res.body; -} - -async function handleUpdateStatus(args: Record<string, unknown>): Promise<unknown> { - const taskId = (args["taskId"] as string) ?? CONTEXT_TASK_ID; - if (!taskId) { - return { error: "no_task_id", message: "taskId required (or set TASK_ID env var)" }; - } - - const body = { - status: args["status"], - evidence: args["evidence"] ?? undefined, - blocker: args["blocker"] ?? undefined, - }; - - const res = await httpRequest("POST", `/tasks/${taskId}/status`, body); - return res.body; -} - -async function handleUpdateMetadata(args: Record<string, unknown>): Promise<unknown> { - const taskId = args["taskId"] as string; - if (!taskId) { - return { error: "no_task_id", message: "taskId is required" }; - } - - const body: Record<string, unknown> = {}; - for (const key of ["priority", "assignee", "reviewer", "consulted", "informed"]) { - if (args[key] !== undefined) { - body[key] = args[key]; - } - } - if (args["reason"]) { - body["reason"] = args["reason"]; - } - - if (Object.keys(body).filter((k) => k !== "reason").length === 0) { - return { error: "empty_patch", message: "No metadata fields to update" }; - } - - const res = await httpRequest("PATCH", `/tasks/${taskId}/metadata`, body); - return res.body; -} - -async function handleReportIncident(args: Record<string, unknown>): Promise<unknown> { - // Incidents are written locally (same as old system) since the daemon - // doesn't have an incident endpoint yet. For now, write to JSONL. - const fs = await import("node:fs"); - const path = await import("node:path"); - - const workspaceDir = process.env["WORKSPACE_DIR"] ?? - process.env["OPENCLAW_STATE_DIR"] ?? - `${process.env["HOME"]}/.openclaw/workspace`; - - const date = new Date().toISOString().slice(0, 10); - const incidentDir = path.join(workspaceDir, "data", "incidents"); - - if (!fs.existsSync(incidentDir)) { - fs.mkdirSync(incidentDir, { recursive: true }); - } - - const incidentId = `INC-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const incident = { - id: incidentId, - ts: new Date().toISOString(), - severity: args["severity"], - category: args["category"], - summary: args["summary"], - detail: args["detail"] ?? null, - tags: args["tags"] ?? [], - source: "mcp-bridge", - }; - - const filePath = path.join(incidentDir, `${date}.jsonl`); - fs.appendFileSync(filePath, JSON.stringify(incident) + "\n"); - - return { incident_id: incidentId, status: "recorded" }; -} - -// --------------------------------------------------------------------------- -// JSON-RPC 2.0 server -// --------------------------------------------------------------------------- - -interface JsonRpcRequest { - jsonrpc: "2.0"; - id: number | string; - method: string; - params?: unknown; -} - -function send(obj: unknown): void { - process.stdout.write(JSON.stringify(obj) + "\n"); -} - -function sendResult(id: number | string, result: unknown): void { - send({ jsonrpc: "2.0", id, result }); -} - -function sendError(id: number | string, code: number, message: string): void { - send({ jsonrpc: "2.0", id, error: { code, message } }); -} - -async function handleMessage(msg: JsonRpcRequest): Promise<void> { - const { id, method, params } = msg; - - switch (method) { - case "initialize": { - sendResult(id, { - protocolVersion: "2024-11-05", - capabilities: { tools: {} }, - serverInfo: { name: "taskcore-mcp-bridge", version: "0.1.0" }, - }); - return; - } - - case "tools/list": { - sendResult(id, { tools: TOOLS }); - return; - } - - case "tools/call": { - const p = params as { name: string; arguments: Record<string, unknown> }; - const toolName = p.name; - const args = p.arguments ?? {}; - - let result: unknown; - try { - switch (toolName) { - case "delegate": - result = await handleDelegate(args); - break; - case "update_status": - result = await handleUpdateStatus(args); - break; - case "update_metadata": - result = await handleUpdateMetadata(args); - break; - case "report_incident": - result = await handleReportIncident(args); - break; - default: - sendError(id, -32601, `Unknown tool: ${toolName}`); - return; - } - sendResult(id, { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - sendResult(id, { - content: [{ type: "text", text: `Error: ${message}` }], - isError: true, - }); - } - return; - } - - case "notifications/initialized": { - // No response needed for notifications - return; - } - - default: { - if (id !== undefined) { - sendError(id, -32601, `Method not found: ${method}`); - } - } - } -} - -// --------------------------------------------------------------------------- -// Main loop -// --------------------------------------------------------------------------- - -const rl = readline.createInterface({ input: process.stdin }); - -rl.on("line", (line) => { - try { - const msg = JSON.parse(line) as JsonRpcRequest; - handleMessage(msg).catch((err) => { - console.error("[mcp-bridge] Error:", err); - if (msg.id !== undefined) { - sendError(msg.id, -32603, String(err)); - } - }); - } catch { - // Malformed JSON — ignore - } -}); - -rl.on("close", () => { - process.exit(0); -}); diff --git a/middle/prompt.ts b/middle/prompt.ts index 74f6dc9..93af383 100644 --- a/middle/prompt.ts +++ b/middle/prompt.ts @@ -277,16 +277,19 @@ function buildReviewPrompt(core: Core, task: Task, config: Config): string { // Status update instructions sections.push("## How to Submit Your Review"); sections.push(""); + sections.push("Use the task CLI. `TASK_ID` and `TASKCORE_AGENT_ID` are already set for this session."); + sections.push(""); sections.push("```bash"); - sections.push(`# Approve (mark as done):`); - sections.push(`curl -X POST http://127.0.0.1:${config.port}/tasks/${task.id}/status \\`); - sections.push(` -H 'Content-Type: application/json' \\`); - sections.push(` -d '{"status": "done", "evidence": "Review passed: <your review notes>"}'`); - sections.push(""); - sections.push(`# Request changes:`); - sections.push(`curl -X POST http://127.0.0.1:${config.port}/tasks/${task.id}/status \\`); - sections.push(` -H 'Content-Type: application/json' \\`); - sections.push(` -d '{"status": "pending", "evidence": "Changes needed: <specific feedback>"}'`); + sections.push(`# Re-read the latest review context if needed:`); + sections.push(`task review read`); + sections.push(""); + sections.push(`# Record notes as you go (repeat as needed):`); + sections.push(`task review note "Observation about the code or evidence"`); + sections.push(""); + sections.push(`# Finalize the review:`); + sections.push(`task review approve "Why this passes"`); + sections.push(`task review request-changes "What needs to change"`); + sections.push(`task review reject "Fundamental issue requiring re-analysis"`); sections.push("```"); sections.push(""); @@ -324,22 +327,20 @@ function appendAnalysisInstructions(sections: string[], task: Task, config: Conf sections.push(""); sections.push("## How to Report Your Decision"); sections.push(""); + sections.push("Use the task CLI. `TASK_ID` and `TASKCORE_AGENT_ID` are already set for this session."); + sections.push(""); sections.push("Choose ONE of these options:"); sections.push(""); sections.push("```bash"); sections.push(`# Option 1: Execute directly (task is simple enough for one agent)`); - sections.push(`curl -s -X POST http://127.0.0.1:${config.port}/tasks/${task.id}/status \\`); - sections.push(` -H 'Content-Type: application/json' \\`); - sections.push(` -d '{"status": "execute"}'`); + sections.push(`task decide execute`); sections.push(""); sections.push(`# Option 2: Decompose into subtasks (task is too complex for one agent)`); sections.push(`# Start a decomposition session — the response will guide you through adding subtasks one at a time:`); - sections.push(`curl -s -X POST http://127.0.0.1:${config.port}/tasks/${task.id}/decompose/start`); + sections.push(`task decompose start`); sections.push(""); sections.push(`# Option 3: Block (cannot proceed, missing info or impossible)`); - sections.push(`curl -s -X POST http://127.0.0.1:${config.port}/tasks/${task.id}/status \\`); - sections.push(` -H 'Content-Type: application/json' \\`); - sections.push(` -d '{"status": "blocked", "evidence": "Why this cannot proceed"}'`); + sections.push(`task block "Why this cannot proceed"`); sections.push("```"); sections.push(""); sections.push("## Rules"); @@ -385,21 +386,21 @@ function appendDecompositionInstructions(sections: string[], task: Task, config: sections.push(""); sections.push("## How to Submit Your Decomposition"); sections.push(""); - sections.push("Use the incremental decompose CLI — it guides you step by step:"); + sections.push("Use the task CLI. `TASK_ID` and `TASKCORE_AGENT_ID` are already set for this session."); + sections.push(""); + sections.push("Use the incremental decompose flow — `task decompose start` also resumes an in-progress session:"); sections.push(""); sections.push("```bash"); sections.push(`# Step 1: Start a decomposition session`); - sections.push(`curl -s -X POST http://127.0.0.1:${config.port}/tasks/${task.id}/decompose/start`); + sections.push(`task decompose start`); sections.push(""); sections.push(`# Step 2: Add children one at a time (repeat for each subtask)`); - sections.push(`curl -s -X POST http://127.0.0.1:${config.port}/tasks/${task.id}/decompose/add-child \\`); - sections.push(` -H 'Content-Type: application/json' \\`); - sections.push(` -d '{"title": "Subtask title", "description": "...", "costAllocation": 10}'`); + sections.push(`task decompose add "Subtask title" \\`); + sections.push(` --desc "What this child should do" \\`); + sections.push(` --cost 10`); sections.push(""); sections.push(`# Step 3: Commit when all children are added`); - sections.push(`curl -s -X POST http://127.0.0.1:${config.port}/tasks/${task.id}/decompose/commit \\`); - sections.push(` -H 'Content-Type: application/json' \\`); - sections.push(` -d '{"approach": "Brief description of your decomposition strategy"}'`); + sections.push(`task decompose commit "Brief description of your decomposition strategy"`); sections.push("```"); sections.push(""); sections.push("Each response includes guidance for the next step. Optional child fields: `assignee`, `reviewer`, `dependsOnSiblings` (0-based sibling indices)."); @@ -416,18 +417,24 @@ function appendDecompositionInstructions(sections: string[], task: Task, config: function appendExecutionInstructions(sections: string[], task: Task, config: Config): void { sections.push("## How to Report Status"); sections.push(""); + sections.push("Use the task CLI. `TASK_ID` and `TASKCORE_AGENT_ID` are already set for this session."); + sections.push(""); sections.push("When done, report your status:"); sections.push(""); sections.push("```bash"); - sections.push(`# Work complete → submit for review:`); - sections.push(`curl -s -X POST http://127.0.0.1:${config.port}/tasks/${task.id}/status \\`); - sections.push(` -H 'Content-Type: application/json' \\`); - sections.push(` -d '{"status": "review", "evidence": "Description of what you did"}'`); + sections.push(`# Optional progress note:`); + sections.push(`task update "Short progress update"`); + sections.push(""); + if (task.reviewConfig === null) { + sections.push(`# Work complete → complete directly (no reviewer configured):`); + sections.push(`task complete "Description of what you did"`); + } else { + sections.push(`# Work complete → submit for review:`); + sections.push(`task submit "Description of what you did"`); + } sections.push(""); sections.push(`# Blocked → cannot proceed:`); - sections.push(`curl -s -X POST http://127.0.0.1:${config.port}/tasks/${task.id}/status \\`); - sections.push(` -H 'Content-Type: application/json' \\`); - sections.push(` -d '{"status": "blocked", "evidence": "What is blocking you"}'`); + sections.push(`task block "What is blocking you"`); sections.push("```"); sections.push(""); sections.push("## Rules"); diff --git a/middle/task-update-bridge.py b/middle/task-update-bridge.py deleted file mode 100644 index 27a0eac..0000000 --- a/middle/task-update-bridge.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python3 -""" -task-update-bridge.py — Drop-in replacement for task_update.py that routes -status updates to the taskcore daemon HTTP API. - -Same CLI interface as the original: - python3 task-update-bridge.py --task-id N --status S [--evidence E] [--blocker B] - -Environment: - ORCHESTRATOR_PORT (default 18800) -""" - -import argparse -import json -import os -import sys -import urllib.request -import urllib.error - -PORT = int(os.environ.get("ORCHESTRATOR_PORT", "18800")) -BASE = f"http://127.0.0.1:{PORT}" - -STATUS_MAP = { - "pending": "pending", - "in_progress": "pending", # Return to work = changes_requested - "in-progress": "pending", - "changes_requested": "pending", - "changes-requested": "pending", - "review": "review", - "blocked": "blocked", - "done": "done", -} - - -def post(path: str, body: dict) -> dict: - url = f"{BASE}{path}" - data = json.dumps(body).encode("utf-8") - req = urllib.request.Request( - url, - data=data, - headers={"Content-Type": "application/json"}, - method="POST", - ) - try: - with urllib.request.urlopen(req) as resp: - return json.loads(resp.read().decode("utf-8")) - except urllib.error.HTTPError as e: - body_text = e.read().decode("utf-8", errors="replace") - try: - return json.loads(body_text) - except json.JSONDecodeError: - return {"error": f"HTTP {e.code}", "message": body_text} - - -def main(): - parser = argparse.ArgumentParser( - description="Update task status via taskcore daemon" - ) - parser.add_argument("--task-id", required=True, type=str, help="Task ID") - parser.add_argument( - "--status", - required=True, - choices=[ - "pending", "in_progress", "in-progress", - "changes_requested", "changes-requested", - "review", "blocked", "done", - ], - help="New status", - ) - parser.add_argument("--evidence", default=None, help="Evidence / notes") - parser.add_argument("--blocker", default=None, help="Blocker description") - parser.add_argument("--comment", default=None, help="Additional comment") - parser.add_argument("--next", default=None, help="Next steps (appended to evidence)") - - args = parser.parse_args() - - # Normalize status - mapped = STATUS_MAP.get(args.status) - if mapped is None: - print(json.dumps({"error": f"Unknown status: {args.status}"})) - sys.exit(1) - - # Build evidence string - evidence_parts = [] - if args.evidence: - evidence_parts.append(args.evidence) - if args.comment: - evidence_parts.append(f"Comment: {args.comment}") - if args.next: - evidence_parts.append(f"Next: {args.next}") - evidence = "\n".join(evidence_parts) if evidence_parts else None - - # Build request body - body = {"status": mapped} - if evidence: - body["evidence"] = evidence - if args.blocker: - body["blocker"] = args.blocker - - result = post(f"/tasks/{args.task_id}/status", body) - print(json.dumps(result, indent=2)) - - -if __name__ == "__main__": - main() diff --git a/middle/test/prompt.test.ts b/middle/test/prompt.test.ts new file mode 100644 index 0000000..15ecdde --- /dev/null +++ b/middle/test/prompt.test.ts @@ -0,0 +1,119 @@ +import * as assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, test } from "node:test"; +import { OrchestrationCore } from "../../core/index.js"; +import { DEFAULT_ATTEMPT_BUDGETS } from "../../core/types.js"; +import type { Config } from "../config.js"; +import { loadConfig } from "../config.js"; +import { initJournalRepo } from "../journal.js"; +import { buildPrompt } from "../prompt.js"; + +let core: OrchestrationCore; +let config: Config; +let tmpDir: string; + +function createTask( + taskId: string, + initialPhase: "analysis" | "execution", + reviewer: string | null, +): void { + const result = core.submit({ + type: "TaskCreated", + taskId, + ts: Date.now(), + title: `Task ${taskId}`, + description: `Description for T${taskId}`, + parentId: null, + rootId: taskId, + initialPhase, + initialCondition: "ready", + attemptBudgets: DEFAULT_ATTEMPT_BUDGETS, + costBudget: 100, + dependencies: [], + reviewConfig: reviewer + ? { required: true, attemptBudget: 2, isolationRules: [] } + : null, + skipAnalysis: initialPhase !== "analysis", + metadata: { + assignee: "coder", + ...(reviewer ? { reviewer } : {}), + }, + source: { type: "middle", id: "test" }, + }); + + assert.equal(result.ok, true); +} + +describe("prompt task CLI instructions", () => { + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-prompt-test-")); + const dbPath = path.join(tmpDir, "test.db"); + const workspaceDir = path.join(tmpDir, "workspace"); + const journalRepoPath = path.join(tmpDir, "journal"); + const worktreeBaseDir = path.join(tmpDir, "worktrees"); + + fs.mkdirSync(workspaceDir, { recursive: true }); + initJournalRepo(journalRepoPath); + + core = new OrchestrationCore({ + dbPath, + invariantChecks: true, + snapshotEvery: 50, + }); + + config = { + ...loadConfig(), + dbPath, + workspaceDir, + journalRepoPath, + worktreeBaseDir, + agentRegistry: path.join(tmpDir, "registry.json"), + runtimeFile: "", + lifecycleFile: "", + }; + }); + + afterEach(() => { + core.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("analysis prompt uses task CLI commands", () => { + createTask("1", "analysis", "overseer"); + + const prompt = buildPrompt(core, "1", "work", config); + assert.ok(prompt.includes("task decide execute")); + assert.ok(prompt.includes("task decompose start")); + assert.ok(prompt.includes("task block \"Why this cannot proceed\"")); + assert.ok(!prompt.includes("curl -X POST")); + }); + + test("execution prompt uses task submit when review is required", () => { + createTask("2", "execution", "overseer"); + + const prompt = buildPrompt(core, "2", "work", config); + assert.ok(prompt.includes("task submit \"Description of what you did\"")); + assert.ok(!prompt.includes("curl -s -X POST")); + }); + + test("execution prompt uses task complete when no reviewer is configured", () => { + createTask("3", "execution", null); + + const prompt = buildPrompt(core, "3", "work", config); + assert.ok(prompt.includes("task complete \"Description of what you did\"")); + assert.ok(!prompt.includes("task submit \"Description of what you did\"")); + }); + + test("review prompt uses task review commands", () => { + createTask("4", "analysis", "overseer"); + + const prompt = buildPrompt(core, "4", "review", config); + assert.ok(prompt.includes("task review read")); + assert.ok(prompt.includes("task review approve \"Why this passes\"")); + assert.ok(prompt.includes("task review request-changes \"What needs to change\"")); + assert.ok(prompt.includes("task review reject \"Fundamental issue requiring re-analysis\"")); + assert.ok(!prompt.includes("curl -X POST")); + }); +}); diff --git a/task b/task new file mode 100755 index 0000000..ac61256 --- /dev/null +++ b/task @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ -f "$ROOT_DIR/dist/core/cli/task.js" ]; then + exec node "$ROOT_DIR/dist/core/cli/task.js" "$@" +fi + +exec node --import tsx "$ROOT_DIR/core/cli/task.ts" "$@"