@cleocode/goal 0.0.0-stage → 0.9.2

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 +328 -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,158 @@
1
+ // goal/v1: the contract a goal is executed against. Written by the owner (usually
2
+ // through the write-goal skill), locked by hash when a run starts, never edited by
3
+ // the worker agent.
4
+
5
+ export const SCHEMA = "goal/v1" as const
6
+
7
+ export type Expect = {
8
+ exit?: number
9
+ stdout_contains?: string
10
+ stdout_regex?: string
11
+ }
12
+
13
+ export type CommandCheck = { kind: "command"; run: string; expect?: Expect; timeout?: number; runs?: number; live?: boolean }
14
+ export type FileCheck = { kind: "file"; path: string; exists?: boolean }
15
+ export type ContainsCheck = { kind: "contains"; path: string; text?: string; regex?: string }
16
+ export type AbsentCheck = { kind: "absent"; pattern: string; paths?: string[] }
17
+ export type DiffCheck = { kind: "diff"; paths: string[] }
18
+ export type VerifierCheck = { kind: "verifier"; ask: string }
19
+ export type HumanCheck = { kind: "human"; ask: string }
20
+
21
+ export type Check = CommandCheck | FileCheck | ContainsCheck | AbsentCheck | DiffCheck | VerifierCheck | HumanCheck
22
+ export type CheckKind = Check["kind"]
23
+
24
+ export const HOST_KINDS = ["command", "file", "contains", "absent", "diff"] as const
25
+ export const CHECK_KINDS = [...HOST_KINDS, "verifier", "human"] as const
26
+ export const isHostCheck = (check: Check): boolean => (HOST_KINDS as readonly string[]).includes(check.kind)
27
+
28
+ export type Criterion = {
29
+ id: string
30
+ statement: string
31
+ essential: boolean
32
+ check: Check
33
+ /**
34
+ * v0.5.0 — the CLEO acceptance criteria this criterion's evidence demonstrates,
35
+ * `T<id>#AC<n>` (the same form as a `satisfies:` atom). This is the PRECISE,
36
+ * non-positional binding: when the criterion passes, the bridge links its
37
+ * evidence to exactly these ACs. Absent ⇒ the criterion proves no CLEO AC.
38
+ */
39
+ proves?: GoalAcRef[]
40
+ }
41
+
42
+ /**
43
+ * A CLEO acceptance-criterion reference (`proves:` on a criterion). Three
44
+ * `satisfies:` forms are accepted and validated by the parser:
45
+ * `T1234#AC2` a bare AC alias
46
+ * `T1234#<uuid>` a criterion id
47
+ * `T1234#AC2@<14-digit pin>` a pinned AC alias
48
+ * CLEO widened GoalAcRef to all three (agreed 2026-10-16); a locked contract
49
+ * should PREFER the uuid or pinned form, because a bare `AC2` drifts when a
50
+ * task's criteria are renumbered. The type is deliberately loose; the parser
51
+ * (`AC_REF` in parse.ts) is the authority.
52
+ */
53
+ export type GoalAcRef = `T${number}#${string}`
54
+
55
+ export type Assumption = {
56
+ id: string
57
+ text: string
58
+ rationale?: string
59
+ reversible?: boolean
60
+ status: "assumed" | "confirmed" | "vetoed"
61
+ }
62
+
63
+ export type Step = {
64
+ id: string
65
+ title: string
66
+ proves: string[]
67
+ depends_on: string[]
68
+ }
69
+
70
+ /** T061: optional goal priority. Absent is valid and sorts LAST. */
71
+ export type Priority = "low" | "medium" | "high"
72
+ export const PRIORITIES: readonly Priority[] = ["low", "medium", "high"]
73
+ /** Sort weight: high first, absent last, ties by updatedAt descending. */
74
+ export const priorityWeight = (p?: Priority): number => (p === "high" ? 3 : p === "medium" ? 2 : p === "low" ? 1 : 0)
75
+
76
+ export type Budget = {
77
+ turns?: number
78
+ /** Wall-clock budget in milliseconds of active run time. */
79
+ wallMs?: number
80
+ /** Token spend (input + output + reasoning) across the run. */
81
+ tokens?: number
82
+ cost_usd?: number
83
+ }
84
+
85
+ /** Change classes the OWNER lets the agent apply to its own contract. */
86
+ export type AmendClass = "timeout" | "budget" | "plan" | "recover"
87
+ export const AMEND_CLASSES: readonly AmendClass[] = ["timeout", "budget", "plan", "recover"]
88
+
89
+ export type Autonomy = {
90
+ questions: "defer" | "allow"
91
+ on_user_message: "steer" | "pause"
92
+ on_interrupt: "pause" | "resume-on-message"
93
+ /** Owner-set policy: classes the agent may apply itself (default none). */
94
+ amend: AmendClass[]
95
+ /** Owner-set: what the stall detector does. `pause` (default) stops for the
96
+ * owner; `continue` keeps a stalled-but-active run going with a report. */
97
+ on_stall: "pause" | "continue"
98
+ /** v0.8.0: a first visit to a project this loop has never worked in does not
99
+ * stop it. Default false (the contract's stop rule applies). */
100
+ visit_new_projects?: boolean
101
+ /** v0.8.0: an exhausted budget does not stop the loop. Default false. */
102
+ continue_past_budget?: boolean
103
+ }
104
+
105
+ export type VerificationMode = "host" | "host+verifier" | "strict"
106
+
107
+ /** v0.5.0 — the optional CLEO linkage block. Absent ⇒ no CLEO behaviour change. */
108
+ export const CLEO_GATES = ["implemented", "testsPassed", "qaPassed"] as const
109
+ export type CleoGate = (typeof CLEO_GATES)[number]
110
+ /**
111
+ * A task the goal serves. The bare id links the task for reporting; `acs` is the
112
+ * DECLARED, non-positional claim of which gate's evidence proves which AC alias.
113
+ * An AC that is not declared (and has no typed gate) is left open and reported.
114
+ */
115
+ export type CleoTaskLink = { id: string; acs?: Partial<Record<CleoGate, string[]>> }
116
+
117
+ export type Contract = {
118
+ schema: typeof SCHEMA
119
+ id: string
120
+ title: string
121
+ /** T061: optional low/medium/high ordering across lists, palette and picker. */
122
+ priority?: Priority
123
+ /** T031: "<slug>@<lock-prefix>" — this goal replaces that one (owner-approved append, never a rewrite). */
124
+ supersedes?: string
125
+ intent: { verbatim: string }
126
+ outcome: string
127
+ why?: string
128
+ scope: { in: string[]; out: string[] }
129
+ non_goals: string[]
130
+ constraints: string[]
131
+ criteria: Criterion[]
132
+ invariants: Criterion[]
133
+ /** T100: the exact-run execution workspace (root-relative). Host checks, the
134
+ * protect/diff base and the fingerprint resolve here; default is the project
135
+ * root. `.opencode/goals/**` still resolves at the project root. */
136
+ workspace?: string
137
+ /** v0.5.0 — the tasks this goal serves (absent ⇒ no CLEO behaviour change). */
138
+ cleo?: { tasks: CleoTaskLink[] }
139
+ protect: string[]
140
+ assumptions: Assumption[]
141
+ plan: Step[]
142
+ budget: Budget
143
+ stop: { when?: string; escalate_when: string[] }
144
+ autonomy: Autonomy
145
+ verification: {
146
+ mode: VerificationMode
147
+ max_rejections: number
148
+ read: string[]
149
+ /** T107: the contract names the verifier child's model, "providerID/modelID[#variant]". */
150
+ model?: string
151
+ /** T107: the contract names the verifier child's effort (used as the model variant when the model carries none). */
152
+ effort?: string
153
+ }
154
+ execution: { agent?: string }
155
+ meta: Record<string, unknown>
156
+ }
157
+
158
+ export type Issue = { level: "error" | "warning"; path: string; message: string }
@@ -0,0 +1,51 @@
1
+ import type { Contract } from "../contract/types"
2
+ import type { RunState } from "./state"
3
+
4
+ /** Semantic equality, not YAML bytes or object-property order. Statements,
5
+ * essentialness and invariant role are part of the oracle, not just its check. */
6
+ export function canonical(value: unknown): string {
7
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
8
+ if (value && typeof value === "object") return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`
9
+ return JSON.stringify(value) ?? "null"
10
+ }
11
+
12
+ export function amendmentDiff(prior: Contract, next: Contract) {
13
+ const oracles = (contract: Contract) => new Map([
14
+ ...contract.criteria.map((c) => [c.id, canonical({ ...c, invariant: false })] as const),
15
+ ...contract.invariants.map((c) => [c.id, canonical({ ...c, invariant: true })] as const),
16
+ ])
17
+ const before = oracles(prior), after = oracles(next)
18
+ const unchanged = [...after.keys()].filter((id) => before.get(id) === after.get(id))
19
+ const changed = [...after.keys()].filter((id) => before.has(id) && before.get(id) !== after.get(id))
20
+ const added = [...after.keys()].filter((id) => !before.has(id))
21
+ const removed = [...before.keys()].filter((id) => !after.has(id))
22
+ const sections = (Object.keys(next) as Array<keyof Contract>).filter((key) => canonical(prior[key]) !== canonical(next[key]))
23
+ const summary = [
24
+ `outcome: ${prior.outcome === next.outcome ? "unchanged" : "CHANGED"}`,
25
+ `criteria unchanged [${unchanged.join(", ") || "-"}] changed [${changed.join(", ") || "-"}] added [${added.join(", ") || "-"}] removed [${removed.join(", ") || "-"}]`,
26
+ `sections changed [${sections.join(", ") || "-"}]`,
27
+ ].join(" · ")
28
+ const details = sections.map((section) => `${section}:\nBEFORE ${canonical(prior[section])}\nAFTER ${canonical(next[section])}`).join("\n\n")
29
+ return { unchanged, changed, added, removed, sections, summary, details }
30
+ }
31
+
32
+ export function amendedState(state: RunState, prior: Contract, next: Contract, lock: string, rationale: string, at = Date.now()): RunState {
33
+ const result = structuredClone(state)
34
+ const diff = amendmentDiff(prior, next)
35
+ result.amendments = result.amendments.map((a) => a.status === "proposed" ? { ...a, status: "rejected", rationale: "replaced by accepted amendment" } : a)
36
+ result.amendments.push({ change: diff.summary, rationale, at, status: "accepted" })
37
+ result.lock = lock
38
+ result.title = next.title
39
+ if (next.priority) result.priority = next.priority
40
+ else delete result.priority
41
+ for (const id of diff.changed.concat(diff.added)) result.criteria[id] = { status: "unknown", rejections: 0 }
42
+ for (const id of diff.removed) delete result.criteria[id]
43
+ const previousSteps = new Map(prior.plan.map((s) => [s.id, canonical(s)]))
44
+ result.steps = Object.fromEntries(next.plan.map((s) => [s.id, previousSteps.get(s.id) === canonical(s) ? result.steps[s.id] ?? "pending" : "pending"]))
45
+ // An accepted generation is not a claim or a verdict, and is never an
46
+ // implicit resume. The next explicit verification evaluates the new lock.
47
+ delete result.claim
48
+ delete result.verdict
49
+ result.updatedAt = at
50
+ return result
51
+ }
@@ -0,0 +1,72 @@
1
+ import { OwnerReviewStore, processAlive, reviewExpired, type ReviewRequest } from "./owner-reviews"
2
+ import type { RunState } from "./state"
3
+ import type { amendmentDiff } from "./amendment"
4
+
5
+ export type AmendmentRequest = ReviewRequest & {
6
+ targetSessionID: string
7
+ runId: string
8
+ nextLock: string
9
+ nextText: string
10
+ rationale: string
11
+ /** Non-consenting admission pointer: lets chat reach the agent when its
12
+ * matching native question arrives later. Never authorizes a commit. */
13
+ chatAdmissionMessageID?: string
14
+ diff: ReturnType<typeof amendmentDiff>
15
+ /** Host write-ahead record: populated ONLY after authenticated acceptance. */
16
+ commit?: { state: RunState; acceptedAt: number }
17
+ /** Who authorised application: the owner (default), the owner-set autonomy
18
+ * policy for an in-policy agent change, or the owner's revert. */
19
+ appliedBy?: "owner" | "agent-policy" | "owner-revert"
20
+ policyClasses?: string[]
21
+ /** Divergence recovery: the disk contract is ahead of the lock. lock is the
22
+ * locked generation, nextLock/nextText the disk bytes under review. */
23
+ recovery?: { provenance: string[]; diskMtime?: number }
24
+ }
25
+
26
+ export const amendmentChoices = [
27
+ { value: "accept", label: "Accept amendment", description: "Re-lock this exact reviewed diff. Changed oracle evidence resets; unchanged evidence remains. The goal stays stopped." },
28
+ { value: "reject", label: "Reject amendment", description: "Discard this proposal, keeping the current contract and all its evidence. The goal stays stopped." },
29
+ { value: "comment", label: "Comment or revise", description: "Return feedback to the agent for a revised proposal. No contract change or resume is authorized." },
30
+ ] as const
31
+
32
+ export const recoveryChoices = [
33
+ { value: "adopt", label: "Adopt disk version", description: "Make the disk bytes a new journalled generation. Changed criteria reset; unchanged proof remains. The goal stays stopped." },
34
+ { value: "restore", label: "Restore locked version", description: "Write the locked contract back to goal.yaml and keep the disk bytes as evidence. The goal stays stopped." },
35
+ { value: "leave", label: "Leave unchanged", description: "Change nothing. The goal stays stopped until the divergence is resolved." },
36
+ ] as const
37
+
38
+ export class AmendmentStore extends OwnerReviewStore<AmendmentRequest> {
39
+ constructor(file: string) { super(file, amendmentChoices) }
40
+ protected override reviewChoices(request: AmendmentRequest) { return request.recovery ? recoveryChoices : amendmentChoices }
41
+ choicesFor(request: AmendmentRequest) { return this.reviewChoices(request) }
42
+
43
+ noteChatAdmission(id: string, messageID?: string): AmendmentRequest | undefined {
44
+ return this.mutate((data) => {
45
+ const request = data.requests[id]
46
+ if (!request || !["pending", "interrupted"].includes(request.status) || reviewExpired(request) || request.ownerMessageID
47
+ || request.issuerPID !== process.pid && processAlive(request.issuerPID)) return undefined
48
+ if (messageID) request.chatAdmissionMessageID = messageID
49
+ else delete request.chatAdmissionMessageID
50
+ return request
51
+ })
52
+ }
53
+
54
+ takeChatAdmission(id: string, messageID: string): boolean {
55
+ return this.mutate((data) => {
56
+ const request = data.requests[id]
57
+ if (!request || request.status !== "pending" || request.chatAdmissionMessageID !== messageID || request.ownerMessageID || reviewExpired(request)
58
+ || request.issuerPID !== process.pid && processAlive(request.issuerPID)) return false
59
+ delete request.chatAdmissionMessageID
60
+ return true
61
+ })
62
+ }
63
+ writeAhead(id: string, commit: NonNullable<AmendmentRequest["commit"]>): AmendmentRequest {
64
+ return this.mutate((data) => {
65
+ const p = data.requests[id]
66
+ if (!p || p.status !== "executing" || p.issuerPID !== process.pid) throw new Error("No reserved owner acceptance for amendment")
67
+ if (p.commit) throw new Error("Amendment acceptance is already recorded")
68
+ p.commit = commit
69
+ return p
70
+ })
71
+ }
72
+ }
@@ -0,0 +1,339 @@
1
+ // v0.5.0 — the goal→CLEO evidence bridge.
2
+ //
3
+ // Turns a goal's PROVEN work into genuine per-gate CLEO evidence and completes
4
+ // the linked tasks, THROUGH THE `cleo` CLI ONLY — the engine never opens `.cleo`
5
+ // or reads its database.
6
+ //
7
+ // Honesty rules (adopted from the CLEO maintainer, mbx 01M530W19PFBY97GZ8QRYC7FVJ):
8
+ // 1. An AC is attached to a gate ONLY when the evidence on that gate actually
9
+ // demonstrates that AC. ACs are NEVER spread across gates by position — a
10
+ // positional mapping fabricates linkage.
11
+ // 2. Several ACs may sit on one gate; a single AC may be proven by the goal.
12
+ // 3. An AC that no evidence proves stays OPEN and is reported as the exact
13
+ // blocker. The task is then not completed.
14
+ // 4. Every refusal from CLEO is reported verbatim. A closure is never faked.
15
+ //
16
+ // The module is pure where it can be (planning, parsing, report conversion) so
17
+ // it is unit/host testable with injected `git` and `cleo` runners and no real
18
+ // CLI, and it is the same code the real `/goal cleo sync` path executes.
19
+
20
+ import { createHash } from "node:crypto"
21
+ import type { CleoGate } from "../contract/types"
22
+
23
+ export const CLEO_GATES: readonly CleoGate[] = ["implemented", "testsPassed", "qaPassed"]
24
+
25
+ /** A task's change-set: the commit that implemented it and the files it moved. */
26
+ export type ChangeSet = { commit?: string; files: string[]; testFiles: string[] }
27
+
28
+ export type CleoStep = { argv: string[]; gate?: CleoGate; acs: string[] }
29
+
30
+ export type EvidencePlan = {
31
+ task: string
32
+ steps: CleoStep[]
33
+ /** ACs no evidence proves — left open and reported, never positionally bound. */
34
+ openAcs: string[]
35
+ /** Present when the task cannot even be attempted (no change-set, etc.). */
36
+ blocker?: string
37
+ }
38
+
39
+ export type StepResult = { argv: string[]; exit: number; out: string }
40
+ export type TaskOutcome = {
41
+ task: string
42
+ completed: boolean
43
+ steps: StepResult[]
44
+ openAcs: string[]
45
+ blocker?: string
46
+ /** v0.5.1 - the scoped report this task's testsPassed evidence was bound to. */
47
+ report?: { path: string; sha256: string }
48
+ }
49
+
50
+ export type CleoRun = (argv: string[]) => Promise<{ stdout: string; exit: number }>
51
+ export type Git = (args: string[]) => Promise<string>
52
+
53
+ /** The vitest-shaped JSON report CLEO's `test-run:` atom parses. */
54
+ export type VitestReport = {
55
+ numTotalTests: number
56
+ numPassedTests: number
57
+ numFailedTests: number
58
+ numPendingTests: number
59
+ success: boolean
60
+ startTime: number
61
+ testResults: Array<{ name: string; status: "passed" | "failed"; assertionResults: Array<{ status: "passed" | "failed"; fullName: string }> }>
62
+ }
63
+
64
+ /**
65
+ * Convert a real `bun test --reporter=junit` report into the vitest-shaped JSON
66
+ * that `test-run:<json>` expects. A faithful transform: every count and status
67
+ * comes from the actual run; nothing is invented. Pure.
68
+ */
69
+ export function junitToVitestJson(xml: string, startTime: number): VitestReport {
70
+ const num = (attrs: string, name: string) => {
71
+ const m = new RegExp(`${name}="(\\d+)"`).exec(attrs)
72
+ return m ? Number(m[1]) : 0
73
+ }
74
+ const testResults: VitestReport["testResults"] = []
75
+ let total = 0, passed = 0, failed = 0, skipped = 0
76
+ for (const suite of xml.matchAll(/<testsuite\b([^>]*)>([\s\S]*?)<\/testsuite>/g)) {
77
+ const attrs = suite[1] ?? ""
78
+ const body = suite[2] ?? ""
79
+ const name = (/name="([^"]*)"/.exec(attrs) ?? [, "suite"])[1] as string
80
+ const tests = num(attrs, "tests"), failures = num(attrs, "failures"), errors = num(attrs, "errors"), skips = num(attrs, "skipped")
81
+ total += tests
82
+ failed += failures + errors
83
+ skipped += skips
84
+ passed += Math.max(0, tests - failures - errors - skips)
85
+ const assertionResults: VitestReport["testResults"][number]["assertionResults"] = []
86
+ const caseRe = /<testcase\b([^>]*?)(\/?)>/g
87
+ for (let c = caseRe.exec(body); c; c = caseRe.exec(body)) {
88
+ const caseAttrs = c[1] ?? ""
89
+ const fullName = (/name="([^"]*)"/.exec(caseAttrs) ?? [, name])[1] as string
90
+ // A self-closing <testcase/> passes; otherwise look inside its body for a failure child.
91
+ let bad = false
92
+ if (c[2] !== "/") {
93
+ const end = body.indexOf("</testcase>", caseRe.lastIndex)
94
+ const inner = body.slice(c.index, end === -1 ? body.length : end)
95
+ bad = /<failure\b|<error\b/.test(inner)
96
+ }
97
+ assertionResults.push({ status: bad ? "failed" : "passed", fullName })
98
+ }
99
+ testResults.push({ name, status: failures + errors ? "failed" : "passed", assertionResults })
100
+ }
101
+ // bun's junit may omit <testsuite> wrappers for some reporters; fall back to counting testcases.
102
+ if (!testResults.length) {
103
+ const cases = [...xml.matchAll(/<testcase\b[^>]*>/g)].length
104
+ total = cases
105
+ passed = cases
106
+ }
107
+ return { numTotalTests: total, numPassedTests: passed, numFailedTests: failed, numPendingTests: skipped, success: failed === 0, startTime, testResults }
108
+ }
109
+
110
+ /** The LAFS error message from a `cleo` refusal, else the raw tail. Pure. */
111
+ export function cleoRefusal(stdout: string): string {
112
+ const text = stdout.trim()
113
+ const start = text.indexOf("{")
114
+ if (start !== -1) {
115
+ try {
116
+ const parsed = JSON.parse(text.slice(start))
117
+ const message = parsed?.error?.message
118
+ if (typeof message === "string" && message.trim()) return message.trim()
119
+ } catch {
120
+ // not JSON — fall through to the raw tail
121
+ }
122
+ }
123
+ return text.split("\n").slice(-3).join(" ").slice(0, 500) || "cleo refused with no message"
124
+ }
125
+
126
+ /**
127
+ * Build the exact `cleo verify` steps for a task from a DECLARED, non-positional
128
+ * AC map. The caller declares which AC each gate's evidence proves; anything not
129
+ * declared (and not backed by the task's own typed gate) is left open.
130
+ */
131
+ export function planEvidence(input: {
132
+ task: string
133
+ /** The task's AC aliases that are not backed by a typed gate. */
134
+ acs: string[]
135
+ /** AC aliases backed by a typed gate — satisfied by `cleo verify <task> --run`. */
136
+ typedAcs?: string[]
137
+ /** True when the task's typed REQ gate has no cached pass yet. */
138
+ runTyped?: boolean
139
+ acsByGate?: Partial<Record<CleoGate, string[]>>
140
+ changeSet?: ChangeSet
141
+ reportPath?: string
142
+ }): EvidencePlan {
143
+ const { task } = input
144
+ const typed = new Set(input.typedAcs ?? [])
145
+ const declared = new Set(CLEO_GATES.flatMap((g) => input.acsByGate?.[g] ?? []))
146
+ const openAcs = input.acs.filter((ac) => !declared.has(ac) && !typed.has(ac))
147
+ const steps: CleoStep[] = []
148
+ let blocker: string | undefined
149
+ // A typed REQ gate the write cannot link must be executed and cached first.
150
+ if (input.runTyped) steps.push({ argv: ["verify", task, "--run"], acs: [] })
151
+
152
+ const evidenceOf = (gate: CleoGate, acs: string[]): string | undefined => {
153
+ const parts: string[] = []
154
+ if (gate === "implemented") {
155
+ if (!input.changeSet?.commit || !input.changeSet.files.length) return undefined
156
+ parts.push(`commit:${input.changeSet.commit}`)
157
+ parts.push(`files:${input.changeSet.files.join(",")}`)
158
+ }
159
+ if (gate === "testsPassed") {
160
+ if (!input.reportPath) return undefined
161
+ parts.push(`test-run:${input.reportPath}`)
162
+ }
163
+ if (gate === "qaPassed") parts.push("tool:typecheck")
164
+ for (const ac of acs) parts.push(`satisfies:${task}#${ac}`)
165
+ return parts.join(";")
166
+ }
167
+
168
+ for (const gate of CLEO_GATES) {
169
+ const acs = input.acsByGate?.[gate] ?? []
170
+ if (!acs.length) continue
171
+ const evidence = evidenceOf(gate, acs)
172
+ if (!evidence) {
173
+ blocker ??= gate === "implemented" ? "no change-set (no commit or files) to record as implemented evidence" : "no scoped test report is available for the testsPassed gate"
174
+ continue
175
+ }
176
+ steps.push({ argv: ["verify", task, "--gate", gate, "--evidence", evidence], gate, acs })
177
+ }
178
+ return { task, steps, openAcs, ...(blocker ? { blocker } : {}) }
179
+ }
180
+
181
+ /** Run a plan: the verify steps, then `cleo complete` only if no AC is open. */
182
+ export async function executePlan(plan: EvidencePlan, runCleo: CleoRun): Promise<TaskOutcome> {
183
+ const steps: StepResult[] = []
184
+ if (plan.blocker) return { task: plan.task, completed: false, steps, openAcs: plan.openAcs, blocker: plan.blocker }
185
+ for (const step of plan.steps) {
186
+ const { stdout, exit } = await runCleo(step.argv)
187
+ steps.push({ argv: step.argv, exit, out: stdout })
188
+ if (exit !== 0) return { task: plan.task, completed: false, steps, openAcs: plan.openAcs, blocker: `cleo ${step.argv[0]} refused: ${cleoRefusal(stdout)}` }
189
+ }
190
+ if (plan.openAcs.length)
191
+ return { task: plan.task, completed: false, steps, openAcs: plan.openAcs, blocker: `${plan.openAcs.length} acceptance criterion/criteria have no evidence from this goal (left open, not positionally bound): ${plan.openAcs.join(", ")}` }
192
+ const { stdout, exit } = await runCleo(["complete", plan.task])
193
+ steps.push({ argv: ["complete", plan.task], exit, out: stdout })
194
+ return { task: plan.task, completed: exit === 0, steps, openAcs: [], ...(exit === 0 ? {} : { blocker: `cleo complete refused: ${cleoRefusal(stdout)}` }) }
195
+ }
196
+
197
+ /**
198
+ * Derive a task's change-set from the commits whose message references the task
199
+ * id (the `type(T###): …` convention), or from an explicit commit for a legacy
200
+ * task no commit names. The file list comes from `git show`, exactly like the
201
+ * manual pass that defeated human hands.
202
+ */
203
+ export async function deriveChangeSet(task: string, git: Git, explicitCommit?: string): Promise<ChangeSet> {
204
+ let commit = explicitCommit?.trim()
205
+ if (!commit) {
206
+ const found = (await git(["log", "--all", "--format=%H", "--grep", task])).trim().split("\n").filter(Boolean)
207
+ commit = found[0]
208
+ }
209
+ if (!commit) return { files: [], testFiles: [] }
210
+ const isTest = (f: string) => /\.test\.[cm]?tsx?$/.test(f)
211
+ const files = (await git(["show", "--name-only", "--format=", commit])).split("\n").map((s) => s.trim()).filter(Boolean)
212
+ // CLEO binds a report to the TREE it ran on, so the change it judges is the
213
+ // commit PLUS whatever the working tree changed. The report must therefore
214
+ // also run the test files the working tree adds or edits, or CLEO refuses it.
215
+ const working = (await git(["status", "--porcelain"]))
216
+ .split("\n")
217
+ .map((line) => line.slice(3).trim())
218
+ .filter((f) => f && isTest(f))
219
+ return { commit, files, testFiles: [...new Set([...files.filter(isTest), ...working])] }
220
+ }
221
+
222
+ /** The CLEO task ids a set of commit subjects appears to reference (`type(T###): …`). */
223
+ export function suggestLinkedTasks(subjects: string[]): string[] {
224
+ const found = new Set<string>()
225
+ for (const subject of subjects) for (const m of subject.matchAll(/\(([A-Z]{1,4}\d+)\)/g)) found.add(m[1]!)
226
+ return [...found].sort()
227
+ }
228
+
229
+ /**
230
+ * Map a contract's per-criterion `proves: ["T123#AC2"]` bindings to the gate whose
231
+ * evidence demonstrates each AC. The gate follows the criterion's OWN check, not
232
+ * its position (agreed with the CLEO maintainer, mbx 01M532QTTHFWRWT62HEMK278M8):
233
+ *
234
+ * - `command` running tests → `testsPassed`
235
+ * - `command` running typecheck/lint → `qaPassed` (CLEO records it through its own
236
+ * tool runner at head, never from our exit code)
237
+ * - `file` / `contains` / `absent` → `implemented`
238
+ * - `diff` → NO gate: it passes when paths are UNCHANGED,
239
+ * so it is a guard that proves nothing was built
240
+ * - `verifier` / `human` → NO gate: a verdict alone never satisfies
241
+ * implemented or testsPassed
242
+ *
243
+ * An AC whose criterion maps to no gate stays OPEN and is reported. Pure.
244
+ */
245
+ export function acsByGateFromProves(
246
+ criteria: ReadonlyArray<{ check: { kind: string; run?: string; [key: string]: unknown }; proves?: string[] }>,
247
+ task: string,
248
+ ): Partial<Record<CleoGate, string[]>> {
249
+ const gateFor = (check: { kind: string; run?: string }): CleoGate | undefined => {
250
+ if (check.kind === "command") return /\btypecheck\b|\blint\b/.test(String(check.run ?? "")) ? "qaPassed" : "testsPassed"
251
+ if (check.kind === "file" || check.kind === "contains" || check.kind === "absent") return "implemented"
252
+ return undefined
253
+ }
254
+ const map: Partial<Record<CleoGate, string[]>> = {}
255
+ for (const criterion of criteria) {
256
+ const gate = gateFor(criterion.check)
257
+ if (!gate) continue
258
+ for (const ref of criterion.proves ?? []) {
259
+ const [refTask, ac] = ref.split("#")
260
+ if (refTask !== task || !ac) continue
261
+ map[gate] = [...(map[gate] ?? []), ac]
262
+ }
263
+ }
264
+ return map
265
+ }
266
+
267
+ /** The sha256 of any bytes, hex. Used to bind a report to its exact content. */
268
+ export const sha256Hex = (bytes: string | Uint8Array): string => createHash("sha256").update(bytes).digest("hex")
269
+
270
+ export type BridgeReport = {
271
+ reportPath: string
272
+ /** v0.5.1 - sha256 of the report BYTES, so CLEO can bind test-run: to them. */
273
+ reportSha256: string
274
+ passed: boolean
275
+ total: number
276
+ failed: number
277
+ }
278
+
279
+ /** Everything the bridge needs, injected so it is testable without a real CLI. */
280
+ export type BridgeInputs = {
281
+ tasks: string[]
282
+ links: ReadonlyMap<string, { id: string; acs?: Partial<Record<CleoGate, string[]>> }>
283
+ runCleo: CleoRun
284
+ git: Git
285
+ /** Run the given test files and return a vitest-shaped report (undefined when none can be run). */
286
+ report: (testFiles: string[]) => Promise<BridgeReport | undefined>
287
+ /** A legacy task no commit message names may be given its implementing commit explicitly. */
288
+ commitByTask?: Record<string, string>
289
+ }
290
+
291
+ /**
292
+ * The whole bridge for a set of tasks, one per goal. Reads CLEO's OWN AC map,
293
+ * derives each change-set, runs a scoped report, then records the DECLARED
294
+ * per-gate evidence and completes — through the CLI only. Tasks are independent:
295
+ * one refusal never fakes another, and a red report is never recorded as a pass.
296
+ */
297
+ export async function runBridge(input: BridgeInputs): Promise<TaskOutcome[]> {
298
+ const outcomes: TaskOutcome[] = []
299
+ for (const task of input.tasks) {
300
+ const planResponse = await input.runCleo(["done", task, "--plan"])
301
+ let acs: string[] = [], typedAcs: string[] = [], runTyped = false
302
+ try {
303
+ const parsed = JSON.parse(planResponse.stdout.slice(planResponse.stdout.indexOf("{")))
304
+ const mapping: Array<{ alias: string; mapped: boolean }> = parsed?.data?.acMapping ?? []
305
+ acs = mapping.filter((a) => !a.mapped).map((a) => a.alias)
306
+ const typed: Array<{ alias: string; status?: string }> = parsed?.data?.typedGates ?? []
307
+ typedAcs = typed.map((g) => g.alias)
308
+ runTyped = typed.some((g) => g.status !== "pass")
309
+ } catch {
310
+ outcomes.push({ task, completed: false, steps: [], openAcs: [], blocker: `cleo done --plan did not return a usable plan for ${task}: ${cleoRefusal(planResponse.stdout)}` })
311
+ continue
312
+ }
313
+ const changeSet = await deriveChangeSet(task, input.git, input.commitByTask?.[task])
314
+ const report = await input.report(changeSet.testFiles)
315
+ const link = input.links.get(task)
316
+ const declaredTests = link?.acs?.testsPassed?.length ?? 0
317
+ if (report && !report.passed && declaredTests)
318
+ outcomes.push({ task, completed: false, steps: [], openAcs: [], blocker: `the scoped report for ${task} did not pass (${report.failed}/${report.total} failed) — refusing to record testsPassed evidence on a red run` })
319
+ else {
320
+ const outcome = await executePlan(planEvidence({ task, acs, typedAcs, runTyped, acsByGate: link?.acs, changeSet, reportPath: report?.reportPath }), input.runCleo)
321
+ if (report) outcome.report = { path: report.reportPath, sha256: report.reportSha256 }
322
+ outcomes.push(outcome)
323
+ }
324
+ }
325
+ return outcomes
326
+ }
327
+
328
+ /** Render outcomes honestly: what ran, and the exact blocker when it refused. */
329
+ export function bridgeReport(outcomes: TaskOutcome[]): string {
330
+ return (
331
+ outcomes
332
+ .map((o) => {
333
+ const head = o.completed ? `✓ ${o.task} completed through the CLEO CLI` : `✗ ${o.task} not completed`
334
+ const ran = o.steps.map((s) => ` ran: cleo ${s.argv.join(" ")} → exit ${s.exit}`).join("\n")
335
+ return [head, ran, o.blocker ? ` blocker: ${o.blocker}` : ""].filter(Boolean).join("\n")
336
+ })
337
+ .join("\n") || "No linked CLEO tasks."
338
+ )
339
+ }