@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.
- package/CHANGELOG.md +328 -0
- package/LICENSE +21 -0
- package/README.md +141 -2
- package/cli/ocg.ts +149 -0
- package/dist/cli.js +8708 -0
- package/dist/tui.d.ts +2 -0
- package/dist/tui.js +1920 -0
- package/package.json +79 -4
- package/rpc.ts +2 -0
- package/server.ts +3 -0
- package/skills/write-goal/SKILL.md +211 -0
- package/skills/write-goal/assets/goal.template.yaml +38 -0
- package/skills/write-goal/references/examples.md +283 -0
- package/skills/write-goal/references/goal-schema.md +53 -0
- package/skills/write-goal/references/interview.md +36 -0
- package/src/cli/install.ts +125 -0
- package/src/cli/status.ts +116 -0
- package/src/contract/parse.ts +454 -0
- package/src/contract/render.ts +134 -0
- package/src/contract/types.ts +158 -0
- package/src/engine/amendment.ts +51 -0
- package/src/engine/amendments.ts +72 -0
- package/src/engine/cleo-bridge.ts +339 -0
- package/src/engine/cleo-link.ts +150 -0
- package/src/engine/continue-work.ts +131 -0
- package/src/engine/decisions.ts +44 -0
- package/src/engine/dogfood-record.ts +278 -0
- package/src/engine/handoff.ts +102 -0
- package/src/engine/interrupt-policy.ts +45 -0
- package/src/engine/inventory.ts +70 -0
- package/src/engine/launches.ts +82 -0
- package/src/engine/loop-stop.ts +50 -0
- package/src/engine/loop.ts +109 -0
- package/src/engine/next-work.ts +82 -0
- package/src/engine/owner-intent.ts +71 -0
- package/src/engine/owner-reviews.ts +178 -0
- package/src/engine/peer-announce.ts +67 -0
- package/src/engine/peer-transport.ts +78 -0
- package/src/engine/policy.ts +42 -0
- package/src/engine/registry.ts +119 -0
- package/src/engine/similarity.ts +65 -0
- package/src/engine/stale-decisions.ts +68 -0
- package/src/engine/state.ts +185 -0
- package/src/engine/store.ts +263 -0
- package/src/engine/tick-record.ts +129 -0
- package/src/engine/tick.ts +207 -0
- package/src/rpc.ts +182 -0
- package/src/server/app.ts +4320 -0
- package/src/server/help.ts +50 -0
- package/src/server/index.ts +23 -0
- package/src/server/intent.ts +22 -0
- package/src/server/messages.ts +228 -0
- package/src/summary/audit.ts +76 -0
- package/src/summary/build.ts +314 -0
- package/src/tui/dashboard.ts +322 -0
- package/src/tui/decision-form.ts +12 -0
- package/src/tui/format.ts +127 -0
- package/src/tui/index.tsx +718 -0
- package/src/tui/manager.ts +94 -0
- package/src/tui/navigation.ts +11 -0
- package/src/tui/pointer-select.ts +20 -0
- package/src/tui/tracer.ts +51 -0
- package/src/util/git.ts +102 -0
- package/src/util/ids.ts +20 -0
- package/src/util/shell.ts +42 -0
- package/src/verify/artifact.ts +95 -0
- package/src/verify/pipeline.ts +200 -0
- 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
|
+
}
|