@cleocode/goal 0.0.0-stage → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +319 -0
  2. package/LICENSE +21 -0
  3. package/README.md +141 -2
  4. package/cli/ocg.ts +149 -0
  5. package/dist/cli.js +8708 -0
  6. package/dist/tui.d.ts +2 -0
  7. package/dist/tui.js +1920 -0
  8. package/package.json +79 -4
  9. package/rpc.ts +2 -0
  10. package/server.ts +3 -0
  11. package/skills/write-goal/SKILL.md +211 -0
  12. package/skills/write-goal/assets/goal.template.yaml +38 -0
  13. package/skills/write-goal/references/examples.md +283 -0
  14. package/skills/write-goal/references/goal-schema.md +53 -0
  15. package/skills/write-goal/references/interview.md +36 -0
  16. package/src/cli/install.ts +125 -0
  17. package/src/cli/status.ts +116 -0
  18. package/src/contract/parse.ts +454 -0
  19. package/src/contract/render.ts +134 -0
  20. package/src/contract/types.ts +158 -0
  21. package/src/engine/amendment.ts +51 -0
  22. package/src/engine/amendments.ts +72 -0
  23. package/src/engine/cleo-bridge.ts +339 -0
  24. package/src/engine/cleo-link.ts +150 -0
  25. package/src/engine/continue-work.ts +131 -0
  26. package/src/engine/decisions.ts +44 -0
  27. package/src/engine/dogfood-record.ts +278 -0
  28. package/src/engine/handoff.ts +102 -0
  29. package/src/engine/interrupt-policy.ts +45 -0
  30. package/src/engine/inventory.ts +70 -0
  31. package/src/engine/launches.ts +82 -0
  32. package/src/engine/loop-stop.ts +50 -0
  33. package/src/engine/loop.ts +109 -0
  34. package/src/engine/next-work.ts +82 -0
  35. package/src/engine/owner-intent.ts +71 -0
  36. package/src/engine/owner-reviews.ts +178 -0
  37. package/src/engine/peer-announce.ts +67 -0
  38. package/src/engine/peer-transport.ts +78 -0
  39. package/src/engine/policy.ts +42 -0
  40. package/src/engine/registry.ts +119 -0
  41. package/src/engine/similarity.ts +65 -0
  42. package/src/engine/stale-decisions.ts +68 -0
  43. package/src/engine/state.ts +185 -0
  44. package/src/engine/store.ts +263 -0
  45. package/src/engine/tick-record.ts +129 -0
  46. package/src/engine/tick.ts +207 -0
  47. package/src/rpc.ts +182 -0
  48. package/src/server/app.ts +4320 -0
  49. package/src/server/help.ts +50 -0
  50. package/src/server/index.ts +23 -0
  51. package/src/server/intent.ts +22 -0
  52. package/src/server/messages.ts +228 -0
  53. package/src/summary/audit.ts +76 -0
  54. package/src/summary/build.ts +314 -0
  55. package/src/tui/dashboard.ts +322 -0
  56. package/src/tui/decision-form.ts +12 -0
  57. package/src/tui/format.ts +127 -0
  58. package/src/tui/index.tsx +718 -0
  59. package/src/tui/manager.ts +94 -0
  60. package/src/tui/navigation.ts +11 -0
  61. package/src/tui/pointer-select.ts +20 -0
  62. package/src/tui/tracer.ts +51 -0
  63. package/src/util/git.ts +102 -0
  64. package/src/util/ids.ts +20 -0
  65. package/src/util/shell.ts +42 -0
  66. package/src/verify/artifact.ts +95 -0
  67. package/src/verify/pipeline.ts +200 -0
  68. package/tui.tsx +2 -0
@@ -0,0 +1,102 @@
1
+ // v0.8.0 — the launch handoff.
2
+ //
3
+ // A fresh launch used to give the new session the contract and nothing else:
4
+ // no prior run, no red baseline, no "start here". This builder turns facts the
5
+ // host ALREADY holds into a bounded document the kickoff can carry. It is
6
+ // context, not authority: it cannot approve, resume or change anything.
7
+ //
8
+ // Pure so the content is testable without a host. The adapter supplies the
9
+ // facts (prior run, repo head, rehearsal, plan) and writes the two files.
10
+
11
+ export type PriorRun = {
12
+ slug: string
13
+ runId: string
14
+ lock: string
15
+ status: string
16
+ turn: number
17
+ evidence: string
18
+ summary?: string
19
+ }
20
+
21
+ export type HandoffFacts = {
22
+ slug: string
23
+ title: string
24
+ fromSessionID: string
25
+ toSessionID: string
26
+ prior?: PriorRun
27
+ repo: { head?: string; branch?: string; dirty?: string[] }
28
+ baseline?: Array<{ id: string; pass: boolean }>
29
+ nextAction?: { step: string; title: string }
30
+ authority?: { trust: string[]; stale: string[] }
31
+ evidence?: string[]
32
+ now?: number
33
+ }
34
+
35
+ export type HandoffDocument = {
36
+ schema: "opencode-goal/handoff/v1"
37
+ generatedAt: string
38
+ markdown: string
39
+ data: Omit<HandoffFacts, "now">
40
+ }
41
+
42
+ /** A turn-1 digest; the full document stays on disk. */
43
+ export const HANDOFF_DIGEST_LIMIT = 8_000
44
+
45
+ export function buildHandoff(facts: HandoffFacts): HandoffDocument {
46
+ const generatedAt = new Date(facts.now ?? Date.now()).toISOString()
47
+ const lines: string[] = []
48
+ lines.push(`# Handoff — ${facts.slug}`)
49
+ lines.push(`Generated ${generatedAt} for a fresh goal session. This is context, not authority: it cannot approve a criterion, accept an amendment, resume a stopped goal or change a contract.`)
50
+ lines.push("")
51
+ lines.push("## Prior run")
52
+ if (facts.prior) {
53
+ lines.push(`- Goal: ${facts.prior.slug} (run ${facts.prior.runId}, ${facts.prior.status}, turn ${facts.prior.turn})`)
54
+ lines.push(`- Lock: ${facts.prior.lock}`)
55
+ lines.push(`- Evidence: ${facts.prior.evidence}`)
56
+ if (facts.prior.summary) lines.push(`- Summary: ${facts.prior.summary}`)
57
+ } else {
58
+ lines.push("- No prior run in this project. This is the first goal here; start from the contract and the repo.")
59
+ }
60
+ lines.push("")
61
+ lines.push("## Current state")
62
+ lines.push(`- Session: ${facts.fromSessionID} → ${facts.toSessionID}`)
63
+ if (facts.repo.head) lines.push(`- HEAD: ${facts.repo.head}`)
64
+ if (facts.repo.branch) lines.push(`- Branch: ${facts.repo.branch}`)
65
+ lines.push(`- Uncommitted: ${facts.repo.dirty?.length ? facts.repo.dirty.slice(0, 20).join(", ") : "none"}`)
66
+ lines.push("")
67
+ if (facts.baseline?.length) {
68
+ lines.push("## Red baseline (measured before this run started)")
69
+ for (const b of facts.baseline) lines.push(`- ${b.id}: ${b.pass ? "pass" : "failing (red baseline)"}`)
70
+ lines.push("")
71
+ }
72
+ if (facts.nextAction) {
73
+ lines.push("## Start here")
74
+ lines.push(`- ${facts.nextAction.step}: ${facts.nextAction.title}`)
75
+ lines.push("")
76
+ }
77
+ if (facts.authority && (facts.authority.trust.length || facts.authority.stale.length)) {
78
+ lines.push("## Authority")
79
+ if (facts.authority.trust.length) lines.push(`- Trust: ${facts.authority.trust.join(", ")}`)
80
+ if (facts.authority.stale.length) lines.push(`- Stale / do not follow blindly: ${facts.authority.stale.join(", ")}`)
81
+ lines.push("")
82
+ }
83
+ if (facts.evidence?.length) {
84
+ lines.push("## Evidence")
85
+ for (const e of facts.evidence) lines.push(`- ${e}`)
86
+ lines.push("")
87
+ }
88
+ lines.push("## What this document is not")
89
+ lines.push("- It is not a contract: only .opencode/goals/<slug>/goal.yaml is locked and verified.")
90
+ lines.push("- It is not consent: owner decisions still need their own exact review.")
91
+ lines.push("- If it disagrees with the contract, the contract wins and the disagreement is an owner question.")
92
+ const markdown = lines.join("\n")
93
+ const { now: _now, ...data } = facts
94
+ return { schema: "opencode-goal/handoff/v1", generatedAt, markdown, data }
95
+ }
96
+
97
+ /** The bounded block a kickoff tail carries. */
98
+ export function handoffDigest(markdown: string, limit = HANDOFF_DIGEST_LIMIT): string {
99
+ const text = markdown.trim()
100
+ if (text.length <= limit) return text
101
+ return `${text.slice(0, limit - 200).trimEnd()}\n\n…(truncated; the full handoff is in this goal folder as handoff.md)`
102
+ }
@@ -0,0 +1,45 @@
1
+ // v0.8.0 — what a host restart does to a live run.
2
+ //
3
+ // The owner's policy: a restart is not an owner decision. If the contract on
4
+ // disk is still the locked contract and the run's own state is intact, the run
5
+ // resumes by itself. A changed contract, a divergent/repair-needed run or an
6
+ // interrupted write is still a stop: those are exactly the cases where guessing
7
+ // would resume work against bytes nobody approved.
8
+ //
9
+ // Pure: no filesystem, no clock, no OpenCode types. `app.ts` gathers the facts
10
+ // and applies the decision; the unit suite pins every case.
11
+
12
+ import type { Status } from "./state"
13
+
14
+ export type RestartFacts = {
15
+ /** The status persisted before the restart. */
16
+ status: Status
17
+ /** The generation the run is bound to. */
18
+ lock: string
19
+ /** The lock computed from the contract currently on disk (undefined if unreadable). */
20
+ diskLock?: string
21
+ /** run.json parsed cleanly (no duplicate keys, no repair). */
22
+ runIntact: boolean
23
+ /** An amendment/launch journal says a write was in flight when the host died. */
24
+ interruptedWrite?: boolean
25
+ }
26
+
27
+ export type RestartDecision =
28
+ | { action: "resume"; reason: string }
29
+ | { action: "pause"; code: "not-intact" | "interrupted-write" | "contract-missing" | "diverged"; reason: string }
30
+ | { action: "none"; reason: string }
31
+
32
+ /** Only an ACTIVE run can resume; a stopped one is the owner's to restart. */
33
+ export function restartDecision(facts: RestartFacts): RestartDecision {
34
+ if (!["running", "waiting", "verifying"].includes(facts.status))
35
+ return { action: "none", reason: `the run was ${facts.status}; nothing to resume` }
36
+ if (!facts.runIntact)
37
+ return { action: "pause", code: "not-intact", reason: "run state was repaired from its ledger after a corrupt write; inspect it before resuming" }
38
+ if (facts.interruptedWrite)
39
+ return { action: "pause", code: "interrupted-write", reason: "a write was interrupted when the host stopped; the accepted generation is uncertain" }
40
+ if (!facts.diskLock)
41
+ return { action: "pause", code: "contract-missing", reason: "the locked contract could not be read from disk; resume would run against unknown bytes" }
42
+ if (facts.diskLock !== facts.lock)
43
+ return { action: "pause", code: "diverged", reason: `the contract on disk is generation ${facts.diskLock.slice(0, 12)}… but the run is locked to ${facts.lock.slice(0, 12)}…` }
44
+ return { action: "resume", reason: "the lock is unchanged and the run is intact" }
45
+ }
@@ -0,0 +1,70 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import type { Contract } from "../contract/types"
4
+ import { parseContract } from "../contract/parse"
5
+ import type { GoalSummary } from "../rpc"
6
+ import { isActive, isTerminal, type RunState } from "./state"
7
+ import { Store } from "./store"
8
+ import { byPriorityThenRecency } from "./registry"
9
+ import { launchView, type LaunchRequest } from "./launches"
10
+
11
+ const readableRun = (value?: RunState): value is RunState => !!value && typeof value.slug === "string" && typeof value.runId === "string" && typeof value.lock === "string" && typeof value.sessionID === "string" && typeof value.status === "string" && !!value.criteria && typeof value.criteria === "object" && !Array.isArray(value.criteria) && !!value.steps && typeof value.steps === "object"
12
+
13
+ /** Project-local, source-of-truth inventory. Archives are READ ONLY. The
14
+ * machine registry and CLEO task tree are not a substitute for goal folders. */
15
+ export function projectInventory(store: Store, live: Iterable<RunState> = [], options: { detailsForKey?: string; launches?: LaunchRequest[] } = {}): GoalSummary[] {
16
+ const liveBySlug = new Map([...live].filter(readableRun).map((s) => [s.slug, s]))
17
+ const rows: GoalSummary[] = []
18
+ for (const archived of [false, true]) {
19
+ for (const slug of archived ? store.archivedSlugs() : store.slugs()) {
20
+ const dir = archived ? store.archivedPath(slug) : store.dir(slug)
21
+ const issues: string[] = []
22
+ let read: ReturnType<Store["readContract"]> = undefined
23
+ try { read = store.readContract(slug, { archived }) } catch { issues.push("Contract could not be read") }
24
+ for (const i of read?.issues ?? []) if (i.level === "error") issues.push(`${i.path}: ${i.message}`)
25
+ const saved = !archived ? liveBySlug.get(slug) ?? store.readRun(slug) : store.readRun(slug, { archived: true })
26
+ const run = readableRun(saved) ? saved : undefined
27
+ if (existsSync(join(dir, "run.json")) && !run) issues.push("Run state is unreadable; status/progress/session cannot be inferred")
28
+ let contract: Contract | undefined = read?.contract
29
+ // The locked generation, not an unaccepted disk edit, defines the
30
+ // started goal's title, oracle count, plan and relationship metadata.
31
+ if (run && read?.lock !== run.lock) {
32
+ contract = undefined
33
+ for (const name of [`contract-${run.lock}.yaml`, "contract.yaml"]) {
34
+ try {
35
+ const locked = parseContract(readFileSync(join(dir, "evidence", run.runId, name), "utf8"), { slug })
36
+ if (locked.contract && locked.lock === run.lock) { contract = locked.contract; break }
37
+ } catch { /* Try the original snapshot; never mutate history. */ }
38
+ }
39
+ issues.push("Disk contract differs from the locked generation")
40
+ }
41
+ const states = run ? Object.values(run.criteria).filter((c) => c && typeof c === "object") : []
42
+ const status = run?.status ?? (existsSync(join(dir, "run.json")) ? "unknown" : "draft")
43
+ const terminal = run ? isTerminal(run.status) : false
44
+ let updatedAt = run?.updatedAt ?? 0
45
+ if (!run) try { updatedAt = statSync(join(dir, "goal.yaml")).mtimeMs } catch { /* Unknown remains 0. */ }
46
+ const key = `${archived ? "archive" : "live"}:${slug}:${run?.runId ?? read?.lock ?? "invalid"}`
47
+ const launch = !archived ? options.launches?.filter((p) => p.slug === slug).sort((a, b) => Number(b.status === "executing") - Number(a.status === "executing") || b.createdAt - a.createdAt)[0] : undefined
48
+ rows.push({
49
+ key,
50
+ slug, title: run?.title ?? contract?.title ?? slug, status,
51
+ archived, terminal, attachable: !archived && !!run && !terminal && !isActive(run.status),
52
+ valid: !!contract && !issues.length, updatedAt,
53
+ proven: states.filter((c) => c.status === "pass").length,
54
+ total: run ? states.length : contract ? contract.criteria.length + contract.invariants.length : 0,
55
+ ...(run ? { sessionID: run.sessionID, runId: run.runId, lock: run.lock, turn: run.turn, ...(run.progress ? { progress: { ...run.progress } } : {}) } : read?.lock ? { lock: read.lock } : {}),
56
+ ...(run?.handoff ? { handoff: { ...run.handoff } } : {}),
57
+ ...(launch ? { launch: launchView(launch) } : {}),
58
+ ...(run?.priority ?? contract?.priority ? { priority: run?.priority ?? contract?.priority } : {}),
59
+ ...(contract ? { outcome: contract.outcome, meta: contract.meta, ...(contract.supersedes ? { supersedes: contract.supersedes } : {}), ...(contract.execution.agent ? { configuredAgent: contract.execution.agent } : {}), steps: contract.plan.map((s) => ({ id: s.id, title: s.title, status: run?.steps[s.id] ?? "pending" })) } : {}),
60
+ sessionState: run || existsSync(join(dir, "run.json")) ? "unknown" : "unassigned",
61
+ ...(issues.length ? { issues } : {}),
62
+ ...(options.detailsForKey === key && contract ? { details: { criteria: [...contract.criteria.map((c) => ({ ...c, invariant: false })), ...contract.invariants.map((c) => ({ ...c, invariant: true }))].map((c) => {
63
+ const state = run?.criteria[c.id]
64
+ return { id: c.id, statement: c.statement, essential: c.essential, invariant: c.invariant, kind: c.check.kind, status: state?.status ?? "unknown", ...(state?.by ? { by: state.by } : {}), ...(state?.detail ? { detail: state.detail } : {}) }
65
+ }), constraints: contract.constraints, nonGoals: contract.non_goals } } : {}),
66
+ })
67
+ }
68
+ }
69
+ return rows.sort(byPriorityThenRecency)
70
+ }
@@ -0,0 +1,82 @@
1
+ import { OwnerReviewStore, processAlive, type ReviewRequest } from "./owner-reviews"
2
+ export { processAlive } from "./owner-reviews"
3
+
4
+ export type LaunchActivity = { phase: "starting" | "rehearsal" | "check"; since: number; detail: string; completed: number; total: number; checkId?: string }
5
+ export type LaunchRequest = ReviewRequest & { fresh: boolean; activity?: LaunchActivity; updatedAt?: number; chatAdmissionMessageID?: string }
6
+ /** Admission is not a run: expose the host receipt without inventing running state. */
7
+ export type LaunchView = Pick<LaunchRequest, "slug" | "sessionID" | "lock" | "fresh" | "status" | "result" | "activity" | "updatedAt" | "expiresAt"> & { decisionId: string }
8
+ export function launchView(p: LaunchRequest): LaunchView {
9
+ return { decisionId: p.id, slug: p.slug, sessionID: p.sessionID, lock: p.lock, fresh: p.fresh, status: p.status, expiresAt: p.expiresAt,
10
+ ...(p.result ? { result: p.result } : {}), ...(p.activity ? { activity: p.activity } : {}), updatedAt: p.updatedAt ?? p.createdAt }
11
+ }
12
+ export const launchChoices = [
13
+ { value: "launch", label: "Start goal now (Recommended)", description: "Launch exactly this validated contract in the displayed session mode." },
14
+ { value: "revise", label: "Revise this goal", description: "Return to the interview with changes; nothing launches." },
15
+ { value: "save", label: "Save for later", description: "Keep the validated draft; nothing launches." },
16
+ ] as const
17
+
18
+ export class LaunchStore extends OwnerReviewStore<LaunchRequest> {
19
+ constructor(file: string) { super(file, launchChoices) }
20
+
21
+ /** Non-consenting admission pointer; actual persisted chat is checked later. */
22
+ noteChatAdmission(id: string, messageID?: string): LaunchRequest | undefined {
23
+ return this.mutate((data) => {
24
+ const request = data.requests[id]
25
+ if (!request || !["pending", "interrupted", "expired"].includes(request.status) || request.ownerMessageID
26
+ || request.issuerPID !== process.pid && processAlive(request.issuerPID)) return undefined
27
+ if (messageID) request.chatAdmissionMessageID = messageID
28
+ else delete request.chatAdmissionMessageID
29
+ return request
30
+ })
31
+ }
32
+
33
+ takeChatAdmission(id: string, messageID: string): boolean {
34
+ return this.mutate((data) => {
35
+ const request = data.requests[id]
36
+ if (!request || !["pending", "interrupted", "expired"].includes(request.status) || request.chatAdmissionMessageID !== messageID || request.ownerMessageID
37
+ || request.issuerPID !== process.pid && processAlive(request.issuerPID)) return false
38
+ delete request.chatAdmissionMessageID
39
+ return true
40
+ })
41
+ }
42
+
43
+ override update(id: string, change: Partial<Pick<ReviewRequest, "status" | "result" | "comment">>): LaunchRequest {
44
+ return this.mutate((data) => {
45
+ const p = data.requests[id]
46
+ if (!p) throw new Error("Unknown launch decision")
47
+ Object.assign(p, change)
48
+ p.updatedAt = Date.now()
49
+ if (p.status !== "executing") delete p.activity
50
+ return p
51
+ })
52
+ }
53
+
54
+ override reserve(id: string): LaunchRequest | undefined {
55
+ return this.mutate((data) => {
56
+ const p = data.requests[id]
57
+ if (!p || p.status !== "pending") return undefined
58
+ if (p.ownerMessageID && Object.values(data.requests).some((other) => other.id !== id && other.ownerMessageID === p.ownerMessageID && ["executing", "succeeded", "failed"].includes(other.status))) return undefined
59
+ if (p.expiresAt < Date.now()) { p.status = "expired"; return undefined }
60
+ if (p.issuerPID !== process.pid && processAlive(p.issuerPID)) return undefined
61
+ // Reserve the actual draft/session too: separate native reviews must
62
+ // not both rehearse or create fresh sessions for one admission.
63
+ if (Object.values(data.requests).some((other) => other.id !== id && other.status === "executing" && (other.slug === p.slug || other.sessionID === p.sessionID))) return undefined
64
+ p.status = "executing"
65
+ p.issuerPID = process.pid
66
+ p.updatedAt = Date.now()
67
+ return p
68
+ })
69
+ }
70
+
71
+ activity(id: string, activity?: LaunchActivity): LaunchRequest {
72
+ return this.mutate((data) => {
73
+ const p = data.requests[id]
74
+ if (!p) throw new Error("Unknown launch decision")
75
+ if (activity && p.status !== "executing") throw new Error("Launch admission is not executing")
76
+ p.updatedAt = Date.now()
77
+ if (activity) p.activity = activity
78
+ else delete p.activity
79
+ return p
80
+ })
81
+ }
82
+ }
@@ -0,0 +1,50 @@
1
+ // v0.8.0 — what still stops the loop.
2
+ //
3
+ // The owner's policy removes lifecycle prompts, not safety. A human criterion,
4
+ // a real blocker and an outward/destructive act always stop and ask. Two more
5
+ // stops are stated by the contract this project is run under (C6): a first visit
6
+ // to a project the loop has never worked in, and an exhausted budget — unless the
7
+ // contract says otherwise. Those two are the only stops an owner can waive, and
8
+ // they are waived explicitly (`allowNewProjects`, `allowBudgetExhaustion`), never
9
+ // by inference.
10
+ //
11
+ // Pure: the adapter gathers the facts, this decides.
12
+
13
+ export type StopFacts = {
14
+ /** A `human` criterion is waiting for owner sign-off. */
15
+ humanCriterionPending?: boolean
16
+ /** A blocker was reported (missing credential/access/decision). */
17
+ blocker?: string
18
+ /** The next step would publish, deploy, merge, delete, spend or reveal secrets. */
19
+ outwardActRequired?: boolean
20
+ /** The source contract's budget is used up. */
21
+ budgetExhausted?: boolean
22
+ /** The contract explicitly allows continuing past its budget. */
23
+ allowBudgetExhaustion?: boolean
24
+ /** The project the candidate belongs to, when a candidate exists. */
25
+ candidateProject?: string
26
+ currentProject: string
27
+ /** Projects this loop has already worked in. */
28
+ visitedProjects?: string[]
29
+ /** The contract explicitly allows a first visit to a new project. */
30
+ allowNewProjects?: boolean
31
+ }
32
+
33
+ export type StopDecision =
34
+ | { stop: false }
35
+ | { stop: true; code: "human" | "blocker" | "outward" | "budget" | "new-project"; reason: string }
36
+
37
+ export function loopStop(facts: StopFacts): StopDecision {
38
+ if (facts.humanCriterionPending)
39
+ return { stop: true, code: "human", reason: "a human criterion needs the owner's sign-off" }
40
+ if (facts.blocker)
41
+ return { stop: true, code: "blocker", reason: `a blocker needs the owner: ${facts.blocker}` }
42
+ if (facts.outwardActRequired)
43
+ return { stop: true, code: "outward", reason: "the next step is outward or destructive; the owner must authorize it" }
44
+ if (facts.budgetExhausted && !facts.allowBudgetExhaustion)
45
+ return { stop: true, code: "budget", reason: "the budget is exhausted and the contract does not allow continuing past it" }
46
+ if (facts.candidateProject && facts.candidateProject !== facts.currentProject
47
+ && !(facts.visitedProjects ?? []).includes(facts.candidateProject) && !facts.allowNewProjects)
48
+ return { stop: true, code: "new-project", reason: `the candidate is in a project this loop has never worked in: ${facts.candidateProject}` }
49
+ return { stop: false }
50
+ }
@@ -0,0 +1,109 @@
1
+ // v0.6.0 - the loop's DECISION, extracted from the OpenCode adapter.
2
+ //
3
+ // `decide(state, signals, limits)` is PURE: no filesystem, no network, no
4
+ // OpenCode types. The adapter calls it from `onTurnEnd` and maps the action onto
5
+ // its existing calls (setStatus, ledger, decide dialog, admit/schedule); the `ocg
6
+ // tick` entry point calls it and maps the action onto CLEO's tick contract. One
7
+ // loop, many harnesses.
8
+ //
9
+ // Parity is the point: each action here corresponds to a branch the adapter
10
+ // already took, and the TailKind of a continuation is the same one the adapter
11
+ // already sent. `test/unit/loop-decision.test.ts` pins every case and
12
+ // docs/research/harness-agnostic-loop-decision.md carries the parity table.
13
+
14
+ import type { TailKind } from "../contract/render"
15
+ import type { RunState, Status } from "./state"
16
+
17
+ /** How the harness reported the turn ending. */
18
+ export type LoopTurn = "succeeded" | "failed" | "interrupted"
19
+
20
+ /** The owner-set and host-set bounds the decision reads (never hard-coded here). */
21
+ export type LoopLimits = {
22
+ blockerRepeats: number
23
+ stallTurns: number
24
+ recoveryAt: number
25
+ backstopTurns: number
26
+ }
27
+
28
+ /** Everything the decision needs, gathered by the caller (all pure data). */
29
+ export type LoopSignals = {
30
+ turn: LoopTurn
31
+ /** The kind of continuation the host had pending for this turn, if any. */
32
+ pending?: TailKind
33
+ /** Host-visible repository change since the last turn. */
34
+ changed: boolean
35
+ /** The agent recorded progress with the progress tool this turn. */
36
+ progressRecorded: boolean
37
+ /** A plan step changed state this turn. */
38
+ stepsChanged: boolean
39
+ /** A stalled run whose recent turns showed host-visible activity. */
40
+ activeStalled: boolean
41
+ /** The reason text for a stall (already formatted by the adapter). */
42
+ stallReason: string
43
+ /** The owner-set stall policy from the contract. */
44
+ onStall: "pause" | "continue"
45
+ /** Budget consumed, 0..1+ (the adapter computes it). */
46
+ budgetRatio: number
47
+ /** No contract budget, and the backstop turn count was reached. */
48
+ backstop: boolean
49
+ /** The adapter's cooldown between scheduled continuations. */
50
+ cooldownMs: number
51
+ now?: number
52
+ }
53
+
54
+ /** What the loop decided to do next. */
55
+ export type LoopAction =
56
+ | { kind: "none" }
57
+ | { kind: "verify" }
58
+ | { kind: "admit"; tail: TailKind }
59
+ | { kind: "schedule"; tail: TailKind; delayMs: number; waiting?: { reason: string } }
60
+ | { kind: "stop"; status: Status; code: "blocked" | "stalled" | "budget" | "wrapup"; reason: string }
61
+
62
+ /**
63
+ * The one decision. Ordering mirrors the adapter exactly:
64
+ * claim -> kickoff -> wrap-up done -> blocked -> budget/backstop -> stall ->
65
+ * wait -> continue (recovery when progress has been missing long enough).
66
+ */
67
+ export function decide(state: RunState, signals: LoopSignals, limits: LoopLimits): LoopAction {
68
+ // A failed or interrupted turn is the adapter's failure path, not this one.
69
+ if (signals.turn !== "succeeded") return { kind: "none" }
70
+ // Only a running run continues; a WAITING run continues only because it claimed.
71
+ if (state.status !== "running" && !(state.status === "waiting" && state.claim)) return { kind: "none" }
72
+ if (state.claim) return { kind: "verify" }
73
+ if (state.turn === 0) return { kind: "admit", tail: "kickoff" }
74
+ if (signals.pending === "wrapup")
75
+ return { kind: "stop", status: "budget_limited", code: "wrapup", reason: "budget used; wrap-up turn done" }
76
+ if (state.blocker && state.blocker.count >= limits.blockerRepeats)
77
+ return { kind: "stop", status: "blocked", code: "blocked", reason: `${state.blocker.key}: ${state.blocker.reason}` }
78
+ if (signals.budgetRatio >= 1 || signals.backstop) {
79
+ // One wrap-up turn is admitted before the run stops for the owner.
80
+ if (!state.wrapup) return { kind: "admit", tail: "wrapup" }
81
+ return { kind: "stop", status: "budget_limited", code: "budget", reason: signals.backstop ? `backstop of ${limits.backstopTurns} turns reached` : "budget used" }
82
+ }
83
+ if (state.counters.noProgress >= limits.stallTurns) {
84
+ // Owner-set policy: a genuinely stalled-but-active run may continue with a
85
+ // recorded report instead of pausing. Idle runs and the default still stop.
86
+ if (signals.activeStalled && signals.onStall === "continue")
87
+ return { kind: "schedule", tail: "continue", delayMs: signals.cooldownMs }
88
+ return { kind: "stop", status: "paused", code: "stalled", reason: signals.stallReason }
89
+ }
90
+ const now = signals.now ?? Date.now()
91
+ if (state.wait && state.wait.until > now)
92
+ return { kind: "schedule", tail: "continue", delayMs: state.wait.until - now, waiting: { reason: state.wait.reason } }
93
+ return { kind: "schedule", tail: state.counters.noProgress >= limits.recoveryAt ? "recovery" : "continue", delayMs: signals.cooldownMs }
94
+ }
95
+
96
+ /**
97
+ * The continuation text this action produces, as the OpenCode adapter would send
98
+ * it. `null` means the loop is not sending a continuation for this action (it is
99
+ * verifying or stopping), which is what the tick reports as `{"final":false}`.
100
+ */
101
+ export function continuationTail(action: LoopAction): TailKind | null {
102
+ switch (action.kind) {
103
+ case "admit":
104
+ case "schedule":
105
+ return action.tail
106
+ default:
107
+ return null
108
+ }
109
+ }
@@ -0,0 +1,82 @@
1
+ // v0.8.0 — what the loop does after a goal completes.
2
+ //
3
+ // Sources, in order (the design note states this too):
4
+ // 1. a ready CLEO item in THIS project — the owner's own task board;
5
+ // 2. work offered by a peer over AgentMBX (a reply to our availability notice);
6
+ // 3. nothing — go idle and announce availability (rate-limited).
7
+ //
8
+ // A candidate is a proposal, never consent: the stop rules are evaluated for
9
+ // every candidate before it can be started, and the adapter records what it
10
+ // started. Pure so the host is not needed to test the decision.
11
+
12
+ import { loopStop, type StopDecision } from "./loop-stop"
13
+ import { announceDue, type AnnounceState } from "./peer-announce"
14
+
15
+ export type WorkCandidate = {
16
+ source: "cleo" | "peer"
17
+ /** Project identity the work belongs to (absolute path or project id). */
18
+ project: string
19
+ taskId?: string
20
+ title: string
21
+ acceptance?: string[]
22
+ /** Peer identity, for peer candidates. */
23
+ from?: string
24
+ messageId?: string
25
+ at?: number
26
+ }
27
+
28
+ export type NextWorkInput = {
29
+ candidates: WorkCandidate[]
30
+ currentProject: string
31
+ visitedProjects?: string[]
32
+ /** Safety stops, evaluated before any candidate. */
33
+ humanCriterionPending?: boolean
34
+ blocker?: string
35
+ outwardActRequired?: boolean
36
+ /** Per-candidate limits from the source contract. */
37
+ budgetExhausted?: boolean
38
+ allowBudgetExhaustion?: boolean
39
+ allowNewProjects?: boolean
40
+ announce: AnnounceState
41
+ now: number
42
+ cooldownMs: number
43
+ }
44
+
45
+ export type NextWorkDecision =
46
+ | { kind: "start"; candidate: WorkCandidate }
47
+ | { kind: "announce" }
48
+ | { kind: "hold"; reason: "outstanding" | "cooldown" | "no-work" }
49
+ | { kind: "stop"; code: Extract<StopDecision, { stop: true }>["code"]; reason: string }
50
+
51
+ export function nextWork(input: NextWorkInput): NextWorkDecision {
52
+ // The three stops no policy may waive.
53
+ const safety = loopStop({
54
+ ...(input.humanCriterionPending ? { humanCriterionPending: true } : {}),
55
+ ...(input.blocker ? { blocker: input.blocker } : {}),
56
+ ...(input.outwardActRequired ? { outwardActRequired: true } : {}),
57
+ currentProject: input.currentProject,
58
+ })
59
+ if (safety.stop) return { kind: "stop", code: safety.code, reason: safety.reason }
60
+
61
+ // CLEO first, then peers. The caller supplies each source already ordered.
62
+ const ordered = [...input.candidates].sort((a, b) => (a.source === b.source ? 0 : a.source === "cleo" ? -1 : 1))
63
+ for (const candidate of ordered) {
64
+ const stop = loopStop({
65
+ ...(input.humanCriterionPending ? { humanCriterionPending: true } : {}),
66
+ ...(input.blocker ? { blocker: input.blocker } : {}),
67
+ ...(input.outwardActRequired ? { outwardActRequired: true } : {}),
68
+ ...(input.budgetExhausted ? { budgetExhausted: true } : {}),
69
+ ...(input.allowBudgetExhaustion ? { allowBudgetExhaustion: true } : {}),
70
+ ...(input.allowNewProjects ? { allowNewProjects: true } : {}),
71
+ candidateProject: candidate.project,
72
+ currentProject: input.currentProject,
73
+ ...(input.visitedProjects ? { visitedProjects: input.visitedProjects } : {}),
74
+ })
75
+ if (stop.stop) return { kind: "stop", code: stop.code, reason: stop.reason }
76
+ return { kind: "start", candidate }
77
+ }
78
+
79
+ const announce = announceDue(input.announce, input.now, input.cooldownMs)
80
+ if (announce.due) return { kind: "announce" }
81
+ return { kind: "hold", reason: announce.reason }
82
+ }
@@ -0,0 +1,71 @@
1
+ /** Bounded owner-command spellings, not an NLP interpreter. The agent still
2
+ * selects a typed action/target. Tool-supplied text is never consent: this
3
+ * message must be fetched from the host's own session history. */
4
+ export function explicitResume(text: string): boolean {
5
+ const value = text.trim()
6
+ if (/\b(?:do not|don't|not|never)\s+(?:resume|continue)\b|\b(?:if|when|unless|until)\b/i.test(value)) return false
7
+ return /^(?:please\s+)?(?:resume|continue)[.!]?$/i.test(value)
8
+ || /^(?:please\s+)?(?:resume|continue)\s+(?:(?:the|my|your)\s+)?goal[.!]?$/i.test(value)
9
+ || /^I (?:approve|authorize) you to resume[.!](?:\s|$)/i.test(value)
10
+ }
11
+
12
+ export function ownerResumeMessage(messages: readonly any[], generationAt: number, review?: { createdAt: number; expiresAt: number; expiryPolicy?: "time" | "target" }) {
13
+ for (const message of [...messages].reverse()) {
14
+ if (message?.type !== "user" || message.metadata?.goal) continue
15
+ if (typeof message.id !== "string" || typeof message.text !== "string" || typeof message.time?.created !== "number") return undefined
16
+ if (message.time.created < generationAt) return undefined
17
+ const text = message.text.trim()
18
+ // Steering/questions and background synthetics do not revoke the owner's
19
+ // existing directive. A later stop, contradiction or conditional does.
20
+ if (/^(?:cancel|stop|pause|abort|reject)\b|\b(?:do not|don't|never)\s+(?:resume|continue)\b|\b(?:withdraw|revoke)\b.*\b(?:approval|consent)\b/i.test(text)
21
+ || /^(?:resume|continue)\b.*\b(?:if|when|unless|until)\b/i.test(text)) return undefined
22
+ // Caller must still match the exact live target; elapsed time alone is
23
+ // not a revocation of a target-bound lifecycle question.
24
+ const acceptsReview = review && message.time.created >= review.createdAt && (review.expiryPolicy === "target" || message.time.created <= review.expiresAt)
25
+ && /^(?:I\s+)?(?:accept|approve)[.!]?$/i.test(text)
26
+ if (explicitResume(text) || acceptsReview) return { id: message.id, text: message.text, createdAt: message.time.created }
27
+ }
28
+ }
29
+
30
+ /** A typed amendment action still needs the host's actual latest owner
31
+ * message. Quoted/design/conditional text and old replies are not consent. */
32
+ export function ownerAmendmentMessage(messages: readonly any[], review: { createdAt: number; expiresAt: number; expiryPolicy?: "time" | "target" }, action: "accept" | "reject" | "comment") {
33
+ const message = [...messages].reverse().find((m) => m?.type === "user" && !m.metadata?.goal)
34
+ if (!message || typeof message.id !== "string" || typeof message.text !== "string" || typeof message.time?.created !== "number"
35
+ || message.time.created < review.createdAt || review.expiryPolicy !== "target" && message.time.created > review.expiresAt) return undefined
36
+ const value = message.text.trim()
37
+ if (!value || action !== "comment" && /["“”`]|\b(?:if|when|unless|until|do not|don't|never|not yet)\b/i.test(value)) return undefined
38
+ const subject = "(?:\\s+(?:(?:this|that|the)\\s+)?(?:amendment|ammendment|proposal|review|change|it))?"
39
+ const continuation = "(?:[,.]?\\s+(?:(?:and|then)\\s+)?(?:please\\s+)?(?:resume(?:\\s+(?:the\\s+)?goal)?|continue(?:\\s+(?:the\\s+)?goal)?|get\\s+it\\s+done|finish\\s+(?:it|the\\s+goal)))?"
40
+ const accepting = new RegExp(`^(?:(?:yes|okay|ok)[,.]?\\s+)?(?:please\\s+)?(?:I\\s+)?(?:accept|approve)${subject}(?:\\s+now)?${continuation}[.!]?$`, "i").test(value)
41
+ const rejecting = new RegExp(`^(?:please\\s+)?(?:I\\s+)?(?:reject|decline)${subject}[.!]?$`, "i").test(value)
42
+ if (action === "accept" && !accepting || action === "reject" && !rejecting || action === "comment" && (accepting || rejecting)) return undefined
43
+ return { id: message.id, text: message.text, createdAt: message.time.created,
44
+ explicitAmendment: /\b(?:amendment|ammendment)\b/i.test(value),
45
+ continueGoal: accepting && /\b(?:resume|continue|finish|get\s+it\s+done)\b/i.test(value) }
46
+ }
47
+
48
+ /** A new explicit chat directive authorizes the last actually displayed
49
+ * contract, not an expired form answer or a worker-supplied consent string. */
50
+ export function ownerLaunchMessage(messages: readonly unknown[], displayed: readonly { id: string; slug: string; lock: string; fresh: boolean; at: number }[], target: { slug: string; lock: string; fresh: boolean }) {
51
+ const latest = [...displayed].sort((a, b) => b.at - a.at)[0]
52
+ if (!latest || latest.slug !== target.slug || latest.lock !== target.lock || latest.fresh !== target.fresh) return undefined
53
+ for (const value of [...messages].reverse()) {
54
+ if (!value || typeof value !== "object") continue
55
+ const message = value as { type?: unknown; id?: unknown; text?: unknown; time?: { created?: unknown }; metadata?: { goal?: unknown } }
56
+ if (message.type !== "user" || message.metadata?.goal) continue
57
+ if (typeof message.id !== "string" || typeof message.text !== "string" || typeof message.time?.created !== "number") return undefined
58
+ const text = message.text.trim()
59
+ if (/^(?:cancel|stop|pause|abort|reject)\b|\b(?:do not|don't|never)\s+(?:start|launch|resume|continue)\b|\b(?:withdraw|revoke)\b.*\b(?:approval|consent)\b/i.test(text)) return undefined
60
+ if (/\b(?:start|launch|resume|continue)\b.*\b(?:if|when|unless|until)\b/i.test(text)) return undefined
61
+ if (/["“”`]|\b(?:if|when|unless|until|not yet)\b/i.test(text)) continue
62
+ const explicit = /^(?:please\s+)?(?:start|launch)\s+(?:(?:the|this|my|your)\s+)?(?:goal|plan)(?:\s+now)?[.!]?$/i.test(text)
63
+ || /^(?:please\s+)?continue\s+(?:(?:the|my|your)\s+)?goal[.!]?$/i.test(text)
64
+ || /^Just go ahead and start the plan[.!]\s*I approve[.!]?$/i.test(text)
65
+ if (!explicit) continue
66
+ const at = message.time.created
67
+ const review = displayed.filter((p) => p.at <= at).sort((a, b) => b.at - a.at)[0]
68
+ if (!review || review.slug !== target.slug || review.lock !== target.lock || review.fresh !== target.fresh) return undefined
69
+ return { id: message.id, text: message.text, createdAt: message.time.created, reviewId: review.id }
70
+ }
71
+ }