@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,314 @@
|
|
|
1
|
+
// T045 — the post-goal summary. One pure builder turning what the run
|
|
2
|
+
// already has (contract, run state, ledger) into a summary the owner and the
|
|
3
|
+
// agent both see on terminal transitions: per-criterion outcomes WITH
|
|
4
|
+
// provenance — owner-approved and fallback-passed criteria carry explicit
|
|
5
|
+
// caveats, never silently equal to host-proven — the scope audit flags
|
|
6
|
+
// (T046), restated non-goals, mid-run findings from the ledger, and
|
|
7
|
+
// follow-up prompts including the CLEO-install suggestion when the project
|
|
8
|
+
// has no .cleo workspace. Rendered by the TUI dialog, a one-line
|
|
9
|
+
// goal.summary notice and a persisted evidence file.
|
|
10
|
+
import type { Contract } from "../contract/types"
|
|
11
|
+
import type { RunState } from "../engine/state"
|
|
12
|
+
|
|
13
|
+
export type Provenance = "host" | "verifier" | "verifier-fallback" | "human"
|
|
14
|
+
|
|
15
|
+
export type SummaryCriterion = {
|
|
16
|
+
id: string
|
|
17
|
+
statement: string
|
|
18
|
+
status: "pass" | "fail" | "unknown"
|
|
19
|
+
by?: Provenance
|
|
20
|
+
detail?: string
|
|
21
|
+
caveat?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ScopeAuditFlag = {
|
|
25
|
+
/** what was discussed but left unproven: a plan step or a scope.in item */
|
|
26
|
+
kind: "step" | "scope"
|
|
27
|
+
id: string
|
|
28
|
+
title: string
|
|
29
|
+
why: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type SummaryFinding = { kind: string; text: string; at?: number }
|
|
33
|
+
|
|
34
|
+
export type PostGoalSummary = {
|
|
35
|
+
slug: string
|
|
36
|
+
title: string
|
|
37
|
+
status: string
|
|
38
|
+
turns: number
|
|
39
|
+
headline: string
|
|
40
|
+
/** One line saying what this run actually finished, for a 3-second read. */
|
|
41
|
+
completed: string
|
|
42
|
+
/** What went right, most load-bearing first. */
|
|
43
|
+
wentRight: string[]
|
|
44
|
+
/** What went wrong (failures, flags, blockers, verifier trouble). */
|
|
45
|
+
wentWrong: string[]
|
|
46
|
+
/** What still needs a fix or an owner decision. Empty ⇒ nothing blocking. */
|
|
47
|
+
stillOpen: string[]
|
|
48
|
+
/** Linked CLEO tasks and follow-ups the run logged. */
|
|
49
|
+
tasks: string[]
|
|
50
|
+
/** Concrete suggested next actions. */
|
|
51
|
+
nextSteps: string[]
|
|
52
|
+
/** A screen-fit digest (≤14 lines) for the dialog; `text` is the full report. */
|
|
53
|
+
digest: string
|
|
54
|
+
criteria: SummaryCriterion[]
|
|
55
|
+
caveats: string[]
|
|
56
|
+
scopeAudit: ScopeAuditFlag[]
|
|
57
|
+
nonGoals: string[]
|
|
58
|
+
findings: SummaryFinding[]
|
|
59
|
+
followUps: string[]
|
|
60
|
+
text: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const countBy = (criteria: SummaryCriterion[], by: Provenance) => criteria.filter((c) => c.status === "pass" && c.by === by).length
|
|
64
|
+
|
|
65
|
+
/** The provenance split on one line, e.g. "host 6 · verifier 2 · owner 1". */
|
|
66
|
+
export function provenanceSplit(criteria: SummaryCriterion[]): string | undefined {
|
|
67
|
+
const parts = [
|
|
68
|
+
["host", countBy(criteria, "host")],
|
|
69
|
+
["verifier", countBy(criteria, "verifier")],
|
|
70
|
+
["owner", countBy(criteria, "human")],
|
|
71
|
+
["fallback", countBy(criteria, "verifier-fallback")],
|
|
72
|
+
].filter(([, n]) => Number(n) > 0) as Array<[string, number]>
|
|
73
|
+
return parts.length ? parts.map(([k, n]) => `${k} ${n}`).join(" · ") : undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The screen-fit digest: what a human needs in 3–5 seconds, then the three
|
|
77
|
+
* things they act on. Hard-capped at 14 lines so it never overflows a
|
|
78
|
+
* dialog — the full report (criteria, non-goals, findings) stays in `text`. */
|
|
79
|
+
export function buildDigest(s: Omit<PostGoalSummary, "digest" | "text">): string {
|
|
80
|
+
const lines: string[] = []
|
|
81
|
+
const icon = s.status === "complete" ? "✓" : s.status === "failed" ? "✗" : s.status === "budget_limited" ? "⊘" : "⚑"
|
|
82
|
+
const proven = s.criteria.filter((c) => c.status === "pass").length
|
|
83
|
+
const terminal = new Set(["complete", "failed", "aborted", "needs_review", "budget_limited"])
|
|
84
|
+
const stopped = terminal.has(s.status) ? "loop stopped" : "loop running"
|
|
85
|
+
lines.push(`${icon} ${s.status.replace(/_/g, " ")} · ${proven}/${s.criteria.length} proven · ${s.turns} turn${s.turns === 1 ? "" : "s"} · ${stopped}`)
|
|
86
|
+
lines.push(s.completed)
|
|
87
|
+
if (s.tasks.length) lines.push(`TASKS ${s.tasks.slice(0, 2).join(" · ")}${s.tasks.length > 2 ? ` · +${s.tasks.length - 2}` : ""}`)
|
|
88
|
+
const section = (title: string, items: string[], mark: string, cap: number, overflow = true) => {
|
|
89
|
+
lines.push(title)
|
|
90
|
+
for (const item of items.slice(0, cap)) lines.push(` ${mark} ${item}`)
|
|
91
|
+
if (overflow && items.length > cap) lines.push(` … +${items.length - cap} more — F9 for all`)
|
|
92
|
+
}
|
|
93
|
+
section("WENT RIGHT", s.wentRight.length ? s.wentRight : ["nothing recorded"], "+", 2)
|
|
94
|
+
section("WENT WRONG", s.wentWrong.length ? s.wentWrong : ["nothing — no failed checks"], "−", 2)
|
|
95
|
+
section("STILL OPEN", s.stillOpen.length ? s.stillOpen : ["nothing blocking"], "·", 1)
|
|
96
|
+
section("NEXT", s.nextSteps.length ? s.nextSteps : ["/goal status"], "→", 2, false)
|
|
97
|
+
return lines.slice(0, 14).join("\n")
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The caveat a provenance carries. Owner approvals and fenced-json
|
|
101
|
+
* fallbacks are real evidence but weaker than host/verifier proof — the
|
|
102
|
+
* summary says so instead of laundering them as equivalent. */
|
|
103
|
+
export function provenanceCaveat(by: Provenance | undefined): string | undefined {
|
|
104
|
+
if (by === "human") return "approved by the owner — final; not independently re-checked"
|
|
105
|
+
if (by === "verifier-fallback") return "parsed from the verifier's prose fallback (fenced json, not a tool call); quotes were re-read by the host"
|
|
106
|
+
return undefined
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const label = (status: string) => (status === "pass" ? "✓ pass" : status === "fail" ? "✗ fail" : "· unknown")
|
|
110
|
+
|
|
111
|
+
/** Mid-run findings worth surfacing at the end, from the run's ledger:
|
|
112
|
+
* criterion flags, blocker keys, empty verifier rounds, fallback parses,
|
|
113
|
+
* amendments and budget stops. Unknown types are ignored. */
|
|
114
|
+
export function findingsFromLedger(events: Array<Record<string, unknown>>): SummaryFinding[] {
|
|
115
|
+
const out: SummaryFinding[] = []
|
|
116
|
+
for (const e of events) {
|
|
117
|
+
const at = typeof e.at === "number" ? e.at : typeof e.turn === "number" ? e.turn : undefined
|
|
118
|
+
if (e.type === "flag") out.push({ kind: "flag", text: `${e.criterion ? `${e.criterion}: ` : ""}${String(e.reason ?? e.kind ?? "flagged")}`, at })
|
|
119
|
+
else if (e.type === "blocked") {
|
|
120
|
+
const blocker = (e.blocker ?? {}) as { key?: string; reason?: string; count?: number }
|
|
121
|
+
out.push({ kind: "blocked", text: `${blocker.key ?? "blocker"} — ${blocker.reason ?? "owner decision needed"}${blocker.count ? ` (raised ${blocker.count}×)` : ""}`, at })
|
|
122
|
+
} else if (e.type === "verifier-empty") out.push({ kind: "verifier-empty", text: `verifier round ${e.round} returned an empty exchange (retried/recorded)`, at })
|
|
123
|
+
else if (e.type === "verifier-fallback") out.push({ kind: "verifier-fallback", text: `${e.count} verdict(s) parsed from a prose-answering verifier`, at })
|
|
124
|
+
else if (e.type === "amended") out.push({ kind: "amended", text: `contract amended (generation ${e.generation ?? "?"}): ${e.summary ?? ""}`, at })
|
|
125
|
+
else if (e.type === "amend-proposed") out.push({ kind: "amend-proposed", text: `amendment proposed: ${e.summary ?? ""}`, at })
|
|
126
|
+
else if (e.type === "budget_limited") out.push({ kind: "budget_limited", text: "the run hit its budget", at })
|
|
127
|
+
}
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type SummaryInput = {
|
|
132
|
+
contract: Contract
|
|
133
|
+
state: RunState
|
|
134
|
+
ledgerEvents?: Array<Record<string, unknown>>
|
|
135
|
+
/** the project's cleo-linkage probe result; absent → install suggestion */
|
|
136
|
+
cleo?: { source: string } | undefined
|
|
137
|
+
/** T046: flagged unproven plan steps / scope.in items; empty until wired */
|
|
138
|
+
scopeAudit?: ScopeAuditFlag[]
|
|
139
|
+
/** v0.8.0 C4: the loop started the next ready item, so it did not stop. */
|
|
140
|
+
continued?: boolean
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function buildPostGoalSummary(input: SummaryInput): PostGoalSummary {
|
|
144
|
+
const { contract, state } = input
|
|
145
|
+
const criteria: SummaryCriterion[] = [...contract.criteria, ...contract.invariants].map((c) => {
|
|
146
|
+
const s = state.criteria[c.id]
|
|
147
|
+
const status = s?.status === "pass" ? ("pass" as const) : s?.status === "fail" ? ("fail" as const) : ("unknown" as const)
|
|
148
|
+
const by = (s?.by as Provenance | undefined) ?? undefined
|
|
149
|
+
return { id: c.id, statement: c.statement, status, by, detail: s?.detail, caveat: status === "pass" ? provenanceCaveat(by) : undefined }
|
|
150
|
+
})
|
|
151
|
+
const caveats = criteria.filter((c) => c.caveat).map((c) => `${c.id} — ${c.caveat}`)
|
|
152
|
+
const scopeAudit = input.scopeAudit ?? []
|
|
153
|
+
const findings = findingsFromLedger(input.ledgerEvents ?? [])
|
|
154
|
+
const proven = criteria.filter((c) => c.status === "pass").length
|
|
155
|
+
|
|
156
|
+
const followUps: string[] = []
|
|
157
|
+
if (state.status === "complete") {
|
|
158
|
+
followUps.push(input.continued
|
|
159
|
+
? "The loop kept going: the next ready item was started by itself. Inspect it in the Goal manager (/goal list)."
|
|
160
|
+
: "Start the next goal: /goal new <what you want done> — the non-goals below are the ready-made backlog.")
|
|
161
|
+
} else if (state.status === "needs_review") {
|
|
162
|
+
followUps.push("Resolve the review: /goal approve <C#> (final) or /goal reject <C#> <why>, then /goal verify.")
|
|
163
|
+
} else if (state.status === "budget_limited") {
|
|
164
|
+
followUps.push("Raise the budget in goal.yaml and start a new run, or wrap up: /goal archive after aborting.")
|
|
165
|
+
}
|
|
166
|
+
if (scopeAudit.length) followUps.push("Break the discussed-but-unproven items out into a follow-up goal so they stop riding on this one.")
|
|
167
|
+
followUps.push("Decompose long-running work with CLEO" + (input.cleo ? " (this project is already linked; keep tasks completing with evidence)." : ": this project has no .cleo workspace — installing it gives goals a task board that outlives any single run (the link stays optional and CLI-only)."))
|
|
168
|
+
|
|
169
|
+
const headline =
|
|
170
|
+
state.status === "complete"
|
|
171
|
+
? `goal complete — ${proven}/${criteria.length} criteria proven${caveats.length ? `, ${caveats.length} with provenance caveats` : ""}`
|
|
172
|
+
: `goal ${state.status} — ${proven}/${criteria.length} criteria proven, ${criteria.length - proven} not yet`
|
|
173
|
+
|
|
174
|
+
// What actually finished, in one sentence a human can scan. The contract's
|
|
175
|
+
// outcome is the end state; the linked CLEO tasks are the durable record.
|
|
176
|
+
const cleoTasks = (contract.cleo?.tasks ?? []).map((t) => t.id)
|
|
177
|
+
const completed = state.status === "complete"
|
|
178
|
+
? `${contract.outcome}${cleoTasks.length ? ` — ${cleoTasks.join(", ")} closed` : ""}`
|
|
179
|
+
: `${contract.outcome} — ${proven}/${criteria.length} proven, ${criteria.length - proven} still open`
|
|
180
|
+
|
|
181
|
+
// What went right: proof first, then the run's own clean facts.
|
|
182
|
+
const wentRight: string[] = []
|
|
183
|
+
const split = provenanceSplit(criteria)
|
|
184
|
+
const rejections = Object.values(state.criteria).reduce((n, c) => n + (c.rejections ?? 0), 0)
|
|
185
|
+
wentRight.push(`${proven}/${criteria.length} criteria proven${split ? ` (${split})` : ""}`)
|
|
186
|
+
if (!criteria.some((c) => c.status === "fail")) wentRight.push(rejections === 0 ? "no failed checks and no rejected claims" : "no failed criteria at the end")
|
|
187
|
+
const acceptedAmendments = state.amendments.filter((a) => a.status === "accepted").length
|
|
188
|
+
if (acceptedAmendments) wentRight.push(`${acceptedAmendments} amendment${acceptedAmendments === 1 ? "" : "s"} accepted and re-locked`)
|
|
189
|
+
|
|
190
|
+
// What went wrong: failures, then the ledger findings that cost time.
|
|
191
|
+
const wentWrong: string[] = []
|
|
192
|
+
for (const c of criteria.filter((c) => c.status === "fail")) {
|
|
193
|
+
wentWrong.push(`${c.id} ${c.statement} — ${(c.detail ?? "failed").split("\n")[0]}`)
|
|
194
|
+
}
|
|
195
|
+
for (const f of findings) {
|
|
196
|
+
if (f.kind === "amended" || f.kind === "amend-proposed") continue
|
|
197
|
+
wentWrong.push(f.text)
|
|
198
|
+
}
|
|
199
|
+
if (rejections) wentWrong.push(`${rejections} rejected claim${rejections === 1 ? "" : "s"} before the final pass`)
|
|
200
|
+
|
|
201
|
+
// What still needs a fix or an owner decision. Empty is a success state.
|
|
202
|
+
const stillOpen: string[] = []
|
|
203
|
+
for (const c of criteria.filter((c) => c.status !== "pass")) stillOpen.push(`${c.id} ${c.statement} — ${c.status}`)
|
|
204
|
+
for (const f of scopeAudit) stillOpen.push(`${f.kind === "step" ? `plan step ${f.id}` : `scope.in ${f.id}`} ${f.title} — discussed but unproven`)
|
|
205
|
+
if (state.blocker) stillOpen.push(`${state.blocker.key} — ${state.blocker.reason}`)
|
|
206
|
+
if (state.status === "needs_review") stillOpen.push("owner review is waiting")
|
|
207
|
+
|
|
208
|
+
// Tasks the run logged: linked CLEO tasks and peer candidates (follow-up
|
|
209
|
+
// prompts are separate, so the digest can stay one short line).
|
|
210
|
+
const tasks: string[] = []
|
|
211
|
+
if (cleoTasks.length) tasks.push(`CLEO ${cleoTasks.join(", ")}`)
|
|
212
|
+
for (const candidate of state.peer?.candidates ?? []) tasks.push(`peer ${candidate.from ?? candidate.project}: ${candidate.title}${candidate.taskId ? ` (${candidate.taskId})` : ""}`)
|
|
213
|
+
|
|
214
|
+
// Suggested next actions, shortest path first.
|
|
215
|
+
const nextSteps: string[] = []
|
|
216
|
+
if (state.status === "complete") {
|
|
217
|
+
nextSteps.push(input.continued ? "Inspect the goal that started by itself — /goal list" : "Start the next goal — /goal new <what you want done>")
|
|
218
|
+
nextSteps.push(`Break this into tasks — /goal decompose ${state.slug}`)
|
|
219
|
+
nextSteps.push("Close it out — /goal archive")
|
|
220
|
+
} else if (state.status === "needs_review") {
|
|
221
|
+
nextSteps.push("Answer the review — /goal approve <C#> or /goal reject <C#> <why>")
|
|
222
|
+
nextSteps.push("Re-run the checks — /goal verify")
|
|
223
|
+
} else if (state.status === "budget_limited") {
|
|
224
|
+
nextSteps.push("Raise the budget in goal.yaml, then start a new run")
|
|
225
|
+
nextSteps.push("Or wrap up — /goal archive")
|
|
226
|
+
} else {
|
|
227
|
+
nextSteps.push("Inspect the goal — /goal status")
|
|
228
|
+
nextSteps.push("Start a goal yourself — /goal new <what you want done>")
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const lines: string[] = []
|
|
232
|
+
lines.push(`# Post-goal summary — ${contract.title}`)
|
|
233
|
+
lines.push(`${headline} · ${state.turn} turn(s)`)
|
|
234
|
+
lines.push("")
|
|
235
|
+
// T071: completion silence read as broken — the summary states plainly
|
|
236
|
+
// what stopped. v0.8.0 C4: when the loop took the next item, this goal is
|
|
237
|
+
// still finished but the loop is not.
|
|
238
|
+
lines.push(input.continued
|
|
239
|
+
? `This goal has stopped (${state.status}); the loop started the next ready item and kept going. This goal itself no longer runs, verifies, or spends.`
|
|
240
|
+
: `The loop has stopped (${state.status}). Nothing further runs, verifies, or spends on its own.`)
|
|
241
|
+
lines.push(`Follow-ups live here: the list below, the Goal panel (F9), and .opencode/goals/${state.slug}/ (ledger, evidence, this summary).`)
|
|
242
|
+
lines.push("")
|
|
243
|
+
lines.push("## The short version")
|
|
244
|
+
lines.push(completed)
|
|
245
|
+
lines.push("")
|
|
246
|
+
lines.push("## What went right")
|
|
247
|
+
for (const item of wentRight) lines.push(`- ${item}`)
|
|
248
|
+
lines.push("")
|
|
249
|
+
lines.push("## What went wrong")
|
|
250
|
+
for (const item of wentWrong.length ? wentWrong : ["nothing — no failed checks, flags, or blockers"]) lines.push(`- ${item}`)
|
|
251
|
+
lines.push("")
|
|
252
|
+
lines.push("## Still needs fixing")
|
|
253
|
+
for (const item of stillOpen.length ? stillOpen : ["nothing — every contracted criterion is proven"]) lines.push(`- ${item}`)
|
|
254
|
+
if (tasks.length) {
|
|
255
|
+
lines.push("")
|
|
256
|
+
lines.push("## Tasks")
|
|
257
|
+
for (const item of tasks) lines.push(`- ${item}`)
|
|
258
|
+
}
|
|
259
|
+
lines.push("")
|
|
260
|
+
lines.push("## Next steps")
|
|
261
|
+
nextSteps.forEach((item, i) => lines.push(`${i + 1}. ${item}`))
|
|
262
|
+
lines.push("")
|
|
263
|
+
lines.push(`Outcome (as contracted): ${contract.outcome}`)
|
|
264
|
+
lines.push("")
|
|
265
|
+
lines.push("## Criteria")
|
|
266
|
+
for (const c of criteria) {
|
|
267
|
+
lines.push(`- ${label(c.status)} ${c.id} [${c.by ?? "unattributed"}] ${c.statement}${c.caveat ? `\n caveat: ${c.caveat}` : ""}${c.status !== "pass" && c.detail ? `\n ${c.detail.split("\n")[0]}` : ""}`)
|
|
268
|
+
}
|
|
269
|
+
if (scopeAudit.length) {
|
|
270
|
+
lines.push("")
|
|
271
|
+
lines.push("## Scope audit — discussed but unproven (flagged, not blocking)")
|
|
272
|
+
for (const f of scopeAudit) lines.push(`- ${f.kind === "step" ? `plan step ${f.id}` : `scope.in ${f.id}`} — ${f.title}: ${f.why}`)
|
|
273
|
+
}
|
|
274
|
+
if (contract.non_goals.length) {
|
|
275
|
+
lines.push("")
|
|
276
|
+
lines.push("## Non-goals (deliberately not done)")
|
|
277
|
+
for (const ng of contract.non_goals) lines.push(`- ${ng}`)
|
|
278
|
+
}
|
|
279
|
+
if (findings.length) {
|
|
280
|
+
lines.push("")
|
|
281
|
+
lines.push("## Mid-run findings")
|
|
282
|
+
for (const f of findings) lines.push(`- ${f.kind}: ${f.text}`)
|
|
283
|
+
}
|
|
284
|
+
if (caveats.length) {
|
|
285
|
+
lines.push("")
|
|
286
|
+
lines.push("## Provenance caveats")
|
|
287
|
+
for (const c of caveats) lines.push(`- ${c}`)
|
|
288
|
+
}
|
|
289
|
+
lines.push("")
|
|
290
|
+
lines.push("## Follow-ups")
|
|
291
|
+
for (const f of followUps) lines.push(`- ${f}`)
|
|
292
|
+
|
|
293
|
+
const base = {
|
|
294
|
+
slug: state.slug,
|
|
295
|
+
title: state.title,
|
|
296
|
+
status: state.status,
|
|
297
|
+
turns: state.turn,
|
|
298
|
+
headline,
|
|
299
|
+
completed,
|
|
300
|
+
wentRight,
|
|
301
|
+
wentWrong,
|
|
302
|
+
stillOpen,
|
|
303
|
+
tasks,
|
|
304
|
+
nextSteps,
|
|
305
|
+
criteria,
|
|
306
|
+
caveats,
|
|
307
|
+
scopeAudit,
|
|
308
|
+
nonGoals: contract.non_goals,
|
|
309
|
+
findings,
|
|
310
|
+
followUps,
|
|
311
|
+
}
|
|
312
|
+
return { ...base, digest: buildDigest(base), text: lines.join("\n") }
|
|
313
|
+
}
|
|
314
|
+
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// T056 — the dashboard rework. The v0.2 panel streamed raw ledger actions:
|
|
2
|
+
// a wall of text, programmatic, not designed for human use — and the sidebar
|
|
3
|
+
// was completely overtaken. Everything here is PURE: the compact card is
|
|
4
|
+
// capped at 12 lines, the panel is organized into Now / Progress / Decisions
|
|
5
|
+
// / Goals tabs with plain-language criteria and a C/I/S legend footer, and
|
|
6
|
+
// actionable rows become keyboard-selectable models the TUI renders as
|
|
7
|
+
// native Select components. The raw ledger timeline is gone from the UI (it
|
|
8
|
+
// stays in the file).
|
|
9
|
+
import type { GoalSummary, GoalView, TimelineEntry } from "../rpc"
|
|
10
|
+
import { bar, elapsed, endStateCopy, fit, fmtTokens, type Line, type Tone } from "./format"
|
|
11
|
+
import { tracerLines } from "./tracer"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
export type DashboardTab = "now" | "progress" | "decisions" | "goals"
|
|
15
|
+
export const DASHBOARD_TABS: DashboardTab[] = ["now", "progress", "decisions", "goals"]
|
|
16
|
+
|
|
17
|
+
const STATUS: Record<string, { icon: string; label: string; tone: Tone }> = {
|
|
18
|
+
running: { icon: "▶", label: "running", tone: "success" },
|
|
19
|
+
waiting: { icon: "◷", label: "waiting", tone: "info" },
|
|
20
|
+
verifying: { icon: "◐", label: "verifying", tone: "info" },
|
|
21
|
+
paused: { icon: "⏸", label: "paused", tone: "warning" },
|
|
22
|
+
blocked: { icon: "⛔", label: "blocked", tone: "error" },
|
|
23
|
+
needs_review: { icon: "⚑", label: "needs review", tone: "warning" },
|
|
24
|
+
budget_limited: { icon: "⊘", label: "budget used", tone: "warning" },
|
|
25
|
+
complete: { icon: "✓", label: "complete", tone: "success" },
|
|
26
|
+
failed: { icon: "✗", label: "failed", tone: "error" },
|
|
27
|
+
aborted: { icon: "■", label: "aborted", tone: "muted" },
|
|
28
|
+
superseded: { icon: "⇢", label: "superseded", tone: "muted" },
|
|
29
|
+
}
|
|
30
|
+
const statusOf = (s: string) => STATUS[s] ?? { icon: "·", label: s, tone: "muted" as Tone }
|
|
31
|
+
|
|
32
|
+
const provenCount = (view: GoalView) => view.criteria.filter((c) => c.status === "pass").length
|
|
33
|
+
|
|
34
|
+
/** (A) The sidebar card: hard-capped at 12 lines. Status, one progress bar,
|
|
35
|
+
* the current step, the next action, ONE action-required line — never a
|
|
36
|
+
* timeline. T073: rows that the width truncates carry a `tab` hint naming
|
|
37
|
+
* the panel tab that holds their full text, and the card always ends with
|
|
38
|
+
* an explicit expand affordance (F9 opens it). */
|
|
39
|
+
export function cardCompactLines(view: GoalView, now: number, width = 40): Line[] {
|
|
40
|
+
const st = statusOf(view.status)
|
|
41
|
+
const lines: Line[] = []
|
|
42
|
+
const head = "◎ Goal"
|
|
43
|
+
const right = `${st.icon} ${st.label}`
|
|
44
|
+
lines.push({ text: `${head}${" ".repeat(Math.max(1, width - head.length - right.length))}${right}`, tone: st.tone, bold: true })
|
|
45
|
+
if (view.actionRequired) lines.push({ text: fit(`⚑ ${view.actionRequired}`, width), tone: "error", bold: true, tab: "decisions" })
|
|
46
|
+
lines.push({ text: fit(view.title, width), tone: "base", bold: true, tab: "now" })
|
|
47
|
+
const total = view.criteria.length
|
|
48
|
+
lines.push({ text: `criteria ${bar(provenCount(view), total)} ${provenCount(view)}/${total}`, tone: provenCount(view) === total && total ? "success" : "muted" })
|
|
49
|
+
// the three criteria that need eyes: failing first, then unproven, then proven
|
|
50
|
+
const focus = [...view.criteria].sort((a, b) => (a.status === "fail" ? -1 : b.status === "fail" ? 1 : a.status === "pass" ? 1 : b.status === "pass" ? -1 : 0)).slice(0, 3)
|
|
51
|
+
for (const c of focus) {
|
|
52
|
+
const mark = c.status === "pass" ? "✓" : c.status === "fail" ? "✗" : "·"
|
|
53
|
+
const tone: Tone = c.status === "pass" ? "success" : c.status === "fail" ? "error" : "muted"
|
|
54
|
+
lines.push({ text: fit(`${mark} ${c.id} ${c.statement}`, width), tone, tab: "progress" })
|
|
55
|
+
}
|
|
56
|
+
if (view.steps.length) {
|
|
57
|
+
const current = view.steps.find((s) => s.status === "active") ?? view.steps.find((s) => s.status !== "done")
|
|
58
|
+
if (current) lines.push({ text: fit(`▸ ${current.id} ${current.title}`, width), tone: "base", tab: "now" })
|
|
59
|
+
}
|
|
60
|
+
const tokens = view.usage.tokens ? ` · ${fmtTokens(view.usage.tokens)} tok` : ""
|
|
61
|
+
lines.push({ text: fit(`turn ${view.turn}${tokens}`, width), tone: view.budgetRatio >= 0.8 ? "warning" : "muted" })
|
|
62
|
+
// T071: at a terminal state the card says the loop stopped and where the
|
|
63
|
+
// follow-ups live (the progress note is moot then). Compact form: the slug
|
|
64
|
+
// path fits the sidebar; the full line lives in the banner and the panel.
|
|
65
|
+
const stopped = endStateCopy(view.status, view.slug)
|
|
66
|
+
if (stopped) lines.push({ text: fit(`■ loop stopped — follow-ups: .opencode/goals/${view.slug}/`, width), tone: stopped.tone })
|
|
67
|
+
else if (view.progress) lines.push({ text: fit(`↻ ${view.progress.note}`, width), tone: "muted", tab: "now" })
|
|
68
|
+
// T073: the expand affordance always occupies the card's last line — the
|
|
69
|
+
// cap stays 12; the sections the width truncated name their panel tab so
|
|
70
|
+
// the affordance is a click-through, not a dead hint. Short enough to stay
|
|
71
|
+
// readable at the narrowest sidebar.
|
|
72
|
+
const tabs = [...new Set(lines.filter((l) => l.tab && l.text.includes("…")).map((l) => l.tab!))]
|
|
73
|
+
const affordance = tabs.length
|
|
74
|
+
? `↳ F9 → ${TAB_LABEL[tabs[0] as DashboardTab]}${tabs.length > 1 ? ` (+${tabs.length - 1})` : ""}`
|
|
75
|
+
: "↳ full card — F9"
|
|
76
|
+
lines.push({ text: fit(affordance, width), tone: "info" })
|
|
77
|
+
return lines.slice(0, 12)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** (T073) The expanded card: the same shape as the compact card but EVERY
|
|
81
|
+
* row rendered fully — statements wrap to the width instead of truncating
|
|
82
|
+
* with `…`, and nothing is dropped for line count. Rendered in the panel
|
|
83
|
+
* (the compact card's affordance opens it at the matching tab); pure and
|
|
84
|
+
* snapshot-tested alongside the compact card. */
|
|
85
|
+
export function cardExpandedLines(view: GoalView, now: number, width = 46): Line[] {
|
|
86
|
+
const st = statusOf(view.status)
|
|
87
|
+
const lines: Line[] = []
|
|
88
|
+
const emit = (text: string, tone: Tone, opts: { bold?: boolean; tab?: DashboardTab } = {}) => {
|
|
89
|
+
const [first, ...rest] = wrap(text, width)
|
|
90
|
+
if (first === undefined) return
|
|
91
|
+
lines.push({ text: first, tone, ...(opts.bold ? { bold: true } : {}), ...(opts.tab ? { tab: opts.tab } : {}) })
|
|
92
|
+
for (const r of rest) lines.push({ text: r, tone })
|
|
93
|
+
}
|
|
94
|
+
emit(`◎ GOAL ${st.icon} ${st.label}${view.reason ? ` — ${view.reason}` : ""}`, st.tone, { bold: true })
|
|
95
|
+
const stopped = endStateCopy(view.status, view.slug)
|
|
96
|
+
if (stopped) emit(stopped.text, stopped.tone)
|
|
97
|
+
emit(view.title, "base", { bold: true, tab: "now" })
|
|
98
|
+
if (view.actionRequired) emit(`⚑ ${view.actionRequired}`, "error", { bold: true, tab: "decisions" })
|
|
99
|
+
lines.push({ text: `criteria ${bar(provenCount(view), view.criteria.length)} ${provenCount(view)}/${view.criteria.length}`, tone: provenCount(view) === view.criteria.length && view.criteria.length ? "success" : "muted", tab: "progress" })
|
|
100
|
+
// every criterion, failing first — the compact card's 3-row focus set
|
|
101
|
+
// fully expanded here
|
|
102
|
+
const ordered = [...view.criteria].sort((a, b) => (a.status === "fail" ? -1 : b.status === "fail" ? 1 : 0))
|
|
103
|
+
for (const c of ordered) {
|
|
104
|
+
const mark = c.status === "pass" ? "✓" : c.status === "fail" ? "✗" : c.status === "claimed" ? "◐" : "·"
|
|
105
|
+
const tone: Tone = c.status === "pass" ? "success" : c.status === "fail" ? "error" : "muted"
|
|
106
|
+
emit(`${mark} ${c.invariant ? "I" : "C"} ${c.id} — ${c.statement}`, tone, { tab: "progress" })
|
|
107
|
+
if (c.detail && c.status !== "pass") emit(` ${c.detail}`, tone)
|
|
108
|
+
}
|
|
109
|
+
if (view.steps.length) {
|
|
110
|
+
lines.push({ text: "plan", tone: "base", bold: true })
|
|
111
|
+
for (const s of view.steps) emit(`S ${s.id} ${s.status === "done" ? "✓" : s.status === "active" ? "▸" : "·"} ${s.title}`, s.status === "active" ? "info" : s.status === "done" ? "success" : "muted", { tab: "now" })
|
|
112
|
+
}
|
|
113
|
+
const elapsedMs = elapsed(view, now)
|
|
114
|
+
const tokens = view.usage.tokens ? ` · ${fmtTokens(view.usage.tokens)} tok` : ""
|
|
115
|
+
const spend = view.usage.cost ? ` · $${view.usage.cost.toFixed(2)}` : ""
|
|
116
|
+
lines.push({ text: `turn ${view.turn} · ${Math.floor(elapsedMs / 60000)}m${tokens}${spend}`, tone: view.budgetRatio >= 0.8 ? "warning" : "muted" })
|
|
117
|
+
if (view.progress) emit(`↻ ${view.progress.note}${view.progress.next ? ` → ${view.progress.next}` : ""}`, "muted", { tab: "now" })
|
|
118
|
+
if (view.verdict) {
|
|
119
|
+
lines.push({ text: `last verdict — ${view.verdict.passed ? "passed" : "failed"}`, tone: view.verdict.passed ? "success" : "warning", bold: true })
|
|
120
|
+
for (const l of view.verdict.lines.slice(0, 5)) emit(` ${l.split("\n")[0]}`, view.verdict.passed ? "success" : "warning")
|
|
121
|
+
}
|
|
122
|
+
return lines
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Word-wrap text to width: the expanded view's "every row fully" guarantee —
|
|
126
|
+
* long statements become multiple lines, never `…`. */
|
|
127
|
+
export const wrap = (text: string, width: number): string[] => {
|
|
128
|
+
const flat = text.replace(/\s+/g, " ").trim()
|
|
129
|
+
if (!flat) return []
|
|
130
|
+
if (flat.length <= width) return [flat]
|
|
131
|
+
const out: string[] = []
|
|
132
|
+
let line = ""
|
|
133
|
+
for (const word of flat.split(" ")) {
|
|
134
|
+
if (!line.length) line = word
|
|
135
|
+
else if (line.length + 1 + word.length <= width) line += ` ${word}`
|
|
136
|
+
else {
|
|
137
|
+
out.push(line)
|
|
138
|
+
line = word
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (line.length) out.push(line)
|
|
142
|
+
// a single word longer than the width still has to land somewhere
|
|
143
|
+
return out.flatMap((l) => (l.length <= width ? [l] : (l.match(new RegExp(`.{1,${width}}`, "g")) ?? [l])))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const TAB_LABEL: Record<DashboardTab, string> = { now: "Now", progress: "Progress", decisions: "Decisions", goals: "Goals" }
|
|
147
|
+
|
|
148
|
+
/** T073: the panel tab the compact card's expand affordance opens on — the
|
|
149
|
+
* highest-priority section the sidebar width truncated (decisions first,
|
|
150
|
+
* then criteria, then step/progress). Undefined when nothing truncated:
|
|
151
|
+
* F9 opens the panel where it was. */
|
|
152
|
+
export function cardAffordanceTab(view: GoalView, now: number, width = 40): DashboardTab | undefined {
|
|
153
|
+
const order: DashboardTab[] = ["decisions", "progress", "now"]
|
|
154
|
+
const truncated = new Set(cardCompactLines(view, now, width).filter((l) => l.tab && l.text.includes("…")).map((l) => l.tab!))
|
|
155
|
+
return order.find((t) => truncated.has(t))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const humanKinds = new Set(["verdict", "approve", "reject", "amend-proposed", "amended", "blocked", "complete", "summary", "flag", "attached", "paused", "resumed", "aborted", "budget_limited", "superseded-by", "approve ", "reject "])
|
|
159
|
+
|
|
160
|
+
/** (T074 sign-off fix) Quick acts: owner actions that ALWAYS make sense for
|
|
161
|
+
* the goal's current state — the Decisions tab is interactive even when no
|
|
162
|
+
* decision is open (the owner's "nothing to select" defect: a wall of text
|
|
163
|
+
* whenever the run is quiet). */
|
|
164
|
+
export function quickActRows(view: GoalView): ActionRow[] {
|
|
165
|
+
const rows: ActionRow[] = []
|
|
166
|
+
if (["running", "waiting", "verifying"].includes(view.status)) {
|
|
167
|
+
rows.push({ label: "Pause the goal", description: "stop the loop; /goal resume picks it up", act: "pause" })
|
|
168
|
+
rows.push({ label: "Verify now", description: "run the host checks + verifier immediately", act: "verify" })
|
|
169
|
+
} else if (view.status === "paused") {
|
|
170
|
+
rows.push({ label: "Resume the goal", description: view.reason ?? "continue the loop", act: "resume" })
|
|
171
|
+
rows.push({ label: "Verify now", description: "run the host checks + verifier immediately", act: "verify" })
|
|
172
|
+
} else if (view.status === "blocked") {
|
|
173
|
+
rows.push({ label: "Resolved — resume", description: view.blocker?.reason ?? "the blocker is fixed", act: "resume" })
|
|
174
|
+
rows.push({ label: "Abort the goal", description: "stop for good; history stays", act: "abort" })
|
|
175
|
+
} else if (view.status === "needs_review") {
|
|
176
|
+
const unproven = view.criteria.filter((c) => !c.invariant && c.status !== "pass")
|
|
177
|
+
for (const c of unproven.slice(0, 3)) rows.push({ label: `Approve ${c.id} (final)`, description: c.statement, act: "approve", arg: c.id })
|
|
178
|
+
rows.push({ label: "Verify now", description: "run the host checks + verifier immediately", act: "verify" })
|
|
179
|
+
} else if (view.status === "budget_limited") {
|
|
180
|
+
rows.push({ label: "Abort the goal", description: "stop at the budget", act: "abort" })
|
|
181
|
+
} else if (isTerminalStatus(view.status)) {
|
|
182
|
+
rows.push({ label: "Archive the goal", description: "demote to goals-archive/ (history intact)", act: "archive" })
|
|
183
|
+
rows.push({ label: "Start the next goal", description: "open the write-goal interview", act: "start-next" })
|
|
184
|
+
}
|
|
185
|
+
rows.push({ label: "Pause/Resume/Verify are also one keystroke away", description: "/goal pause · /goal resume · /goal verify — or the palette (ctrl+p → Goal)" })
|
|
186
|
+
return rows
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const TERMINAL_STATUSES = new Set(["complete", "failed", "aborted", "superseded"])
|
|
190
|
+
const isTerminalStatus = (s: string) => TERMINAL_STATUSES.has(s)
|
|
191
|
+
|
|
192
|
+
/** (E) only human-relevant events surface in the UI — raw turn/admit noise stays in the ledger file. */
|
|
193
|
+
export const humanizedEvents = (view: GoalView, n: number): TimelineEntry[] => {
|
|
194
|
+
const relevant = view.timeline.filter((e) => humanKinds.has(e.kind) || /^(owner|verdict|goal complete|summary|blocked|amended|flag)/i.test(e.text))
|
|
195
|
+
return relevant.slice(-n).reverse()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** (C) actionable rows: keyboard-selectable models dispatching rpc.act. */
|
|
199
|
+
export type ActionRow = { label: string; description: string; act?: string; arg?: string }
|
|
200
|
+
|
|
201
|
+
export function decisionRows(view: GoalView): ActionRow[] {
|
|
202
|
+
const rows: ActionRow[] = []
|
|
203
|
+
if (view.actionRequired) {
|
|
204
|
+
if (view.status === "needs_review") {
|
|
205
|
+
const unproven = view.criteria.filter((c) => !c.invariant && c.status !== "pass")
|
|
206
|
+
for (const c of unproven) rows.push({ label: `Approve ${c.id} (final)`, description: c.statement, act: "approve", arg: c.id })
|
|
207
|
+
rows.push({ label: "Reject a criterion", description: "/goal reject <C#> <why>", act: "reject" })
|
|
208
|
+
} else if (view.status === "blocked") {
|
|
209
|
+
rows.push({ label: "Resolved — resume", description: view.blocker?.reason ?? "the blocker is fixed", act: "resume" })
|
|
210
|
+
rows.push({ label: "Abort the goal", description: "stop for good; history stays", act: "abort" })
|
|
211
|
+
} else if (view.status === "budget_limited") {
|
|
212
|
+
rows.push({ label: "Raise budget and amend", description: "Describe the budget change in the Composer; review the agent's staged diff" })
|
|
213
|
+
rows.push({ label: "Abort the goal", description: "stop at the budget", act: "abort" })
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (view.status === "paused") rows.push({ label: "Resume the goal", description: view.reason ?? "", act: "resume" })
|
|
217
|
+
if (view.status === "complete") rows.push({ label: "Archive the goal", description: "demote to goals-archive/ (history intact)", act: "archive" })
|
|
218
|
+
if (view.amendments) rows.push({ label: "Confirm proposed amendment", description: "/goal amend confirm", act: "amend", arg: "confirm" })
|
|
219
|
+
return rows
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** (T065) Dialogs are not scrollable on the real TUI: a full post-goal
|
|
223
|
+
* summary (~50 lines) overflowed off the owner's screen. Everything shown
|
|
224
|
+
* in a dialog goes through this digest: headline, the first lines, and a
|
|
225
|
+
* pointer to where the rest lives. */
|
|
226
|
+
export function dialogDigest(title: string, text: string, maxLines = 14, maxCols = 100): string {
|
|
227
|
+
const lines = text.split("\n").map((l) => (l.length > maxCols ? `${l.slice(0, maxCols - 1)}…` : l))
|
|
228
|
+
const head = lines.slice(0, maxLines)
|
|
229
|
+
const rest = lines.length - head.length
|
|
230
|
+
return [title, ...head, ...(rest > 0 ? [`… +${rest} more lines — full text: the Goal panel (F9) or .opencode/goals/ evidence` ] : [])].join("\n")
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** (B) the tabbed panel body. Pure; snapshot-tested. T073: the panel is the
|
|
234
|
+
* expanded view of the compact card — every row wraps to the width instead
|
|
235
|
+
* of truncating with `…`, so nothing the card cut off is unreadable here at
|
|
236
|
+
* any width (the TUI passes the panel's real width). T074 sign-off fixes:
|
|
237
|
+
* the ACTIVE tab is bracketed (visual structure beyond a wall of text),
|
|
238
|
+
* section headers carry rules, and the Decisions tab ALWAYS renders a
|
|
239
|
+
* keyboard-selectable act list (open decisions first, then quick acts). */
|
|
240
|
+
export function dashboardLines(view: GoalView, tab: DashboardTab, now: number, width = 46, goals: GoalSummary[] = []): Line[] {
|
|
241
|
+
const st = statusOf(view.status)
|
|
242
|
+
const lines: Line[] = []
|
|
243
|
+
const emit = (text: string, tone: Tone, bold = false) => {
|
|
244
|
+
for (const part of wrap(text, width)) lines.push({ text: part, tone, ...(bold ? { bold: true } : {}) })
|
|
245
|
+
}
|
|
246
|
+
const rule = () => lines.push({ text: "─".repeat(Math.max(8, Math.min(width, 40))), tone: "muted" })
|
|
247
|
+
const tabsHeader = DASHBOARD_TABS.map((t) => (t === tab ? `[${TAB_LABEL[t]}]` : ` ${TAB_LABEL[t]} `)).join("")
|
|
248
|
+
lines.push({ text: fit(`◎ GOAL ${st.icon} ${st.label}`, width), tone: st.tone, bold: true })
|
|
249
|
+
lines.push({ text: fit(tabsHeader, width), tone: "base", bold: true })
|
|
250
|
+
rule()
|
|
251
|
+
lines.push({ text: "", tone: "base" })
|
|
252
|
+
|
|
253
|
+
if (tab === "now") {
|
|
254
|
+
emit(view.title, "base", true)
|
|
255
|
+
emit(view.outcome, "muted")
|
|
256
|
+
lines.push(...tracerLines(view, now, width))
|
|
257
|
+
const current = view.steps.find((s) => s.status === "active") ?? view.steps.find((s) => s.status !== "done")
|
|
258
|
+
if (current) emit(`▸ step ${current.id} ${current.title}`, "base")
|
|
259
|
+
if (view.progress?.next) emit(`next: ${view.progress.next}`, "muted")
|
|
260
|
+
if (view.actionRequired) emit(`⚑ ${view.actionRequired}`, "error", true)
|
|
261
|
+
else if (["running", "waiting", "verifying"].includes(view.status)) emit("✓ nothing needed from you — the loop is working", "success")
|
|
262
|
+
const events = humanizedEvents(view, 3)
|
|
263
|
+
if (events.length) {
|
|
264
|
+
lines.push({ text: "recent", tone: "base", bold: true })
|
|
265
|
+
rule()
|
|
266
|
+
for (const e of events) emit(`· ${e.text}`, "muted")
|
|
267
|
+
}
|
|
268
|
+
} else if (tab === "progress") {
|
|
269
|
+
const groups: Array<[string, typeof view.criteria, Tone]> = [
|
|
270
|
+
["Failed", view.criteria.filter((c) => c.status === "fail"), "error"],
|
|
271
|
+
["In progress", view.criteria.filter((c) => c.status !== "pass" && c.status !== "fail"), "muted"],
|
|
272
|
+
["Proven", view.criteria.filter((c) => c.status === "pass"), "success"],
|
|
273
|
+
]
|
|
274
|
+
emit(`criteria ${bar(provenCount(view), view.criteria.length)} ${provenCount(view)}/${view.criteria.length}`, "base", true)
|
|
275
|
+
for (const [label, items, tone] of groups) {
|
|
276
|
+
if (!items.length) continue
|
|
277
|
+
lines.push({ text: label, tone, bold: true })
|
|
278
|
+
rule()
|
|
279
|
+
for (const c of items) emit(`${c.invariant ? "I" : "C"} ${c.id} — ${c.statement}${c.by ? ` (${c.by})` : ""}`, tone)
|
|
280
|
+
}
|
|
281
|
+
if (view.steps.length) {
|
|
282
|
+
lines.push({ text: "plan", tone: "base", bold: true })
|
|
283
|
+
rule()
|
|
284
|
+
for (const s of view.steps) emit(`S ${s.id} ${s.status === "done" ? "✓" : s.status === "active" ? "▸" : "·"} ${s.title}`, s.status === "active" ? "info" : s.status === "done" ? "success" : "muted")
|
|
285
|
+
}
|
|
286
|
+
if (view.verdict) {
|
|
287
|
+
lines.push({ text: `last verdict — ${view.verdict.passed ? "passed" : "failed"}`, tone: view.verdict.passed ? "success" : "warning", bold: true })
|
|
288
|
+
for (const l of view.verdict.lines.slice(0, 3)) emit(l.split("\n")[0]!, view.verdict.passed ? "success" : "warning")
|
|
289
|
+
}
|
|
290
|
+
} else if (tab === "decisions") {
|
|
291
|
+
lines.push({ text: "act on a row (↑↓ · enter)", tone: "base", bold: true })
|
|
292
|
+
lines.push({ text: "", tone: "base" })
|
|
293
|
+
// the interactive rows render as the Select component in the TUI —
|
|
294
|
+
// these lines are the readable mirror of the same list
|
|
295
|
+
const history = humanizedEvents(view, 6)
|
|
296
|
+
if (history.length) {
|
|
297
|
+
lines.push({ text: "history", tone: "base", bold: true })
|
|
298
|
+
rule()
|
|
299
|
+
for (const e of history) emit(`· ${e.text}`, "muted")
|
|
300
|
+
}
|
|
301
|
+
} else {
|
|
302
|
+
const others = goals.filter((g) => g.slug !== view.slug)
|
|
303
|
+
emit(`this goal: ${view.title} (${st.label})`, "base", true)
|
|
304
|
+
if (others.length) {
|
|
305
|
+
lines.push({ text: "other goals", tone: "base", bold: true })
|
|
306
|
+
rule()
|
|
307
|
+
for (const g of others) emit(`· ${g.slug} — ${g.title} (${g.status})${g.attachable ? " · attachable" : ""}`, g.terminal ? "muted" : "info")
|
|
308
|
+
} else lines.push({ text: "no other goals in this project", tone: "muted" })
|
|
309
|
+
if (view.steps.length) {
|
|
310
|
+
lines.push({ text: "upcoming steps", tone: "base", bold: true })
|
|
311
|
+
rule()
|
|
312
|
+
for (const s of view.steps.filter((s) => s.status !== "done")) emit(`S ${s.id} · ${s.title}`, "muted")
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
lines.push({ text: "", tone: "base" })
|
|
317
|
+
// T073: the legend wraps to two short lines so it never truncates at the
|
|
318
|
+
// panel's narrowest width
|
|
319
|
+
lines.push({ text: fit("C criterion · I invariant · S plan step", width), tone: "muted" })
|
|
320
|
+
lines.push({ text: fit("↑↓/tab/enter to act", width), tone: "muted" })
|
|
321
|
+
return lines
|
|
322
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Forward an actual owner dialog choice into the exact native question.
|
|
2
|
+
* Never match on a label alone or interpret free commentary as a choice. */
|
|
3
|
+
export function decisionFormAnswer(forms: readonly any[], question: string, label: string): { formID: string; answer: Record<string, string> } | undefined {
|
|
4
|
+
for (const form of forms) {
|
|
5
|
+
if (form.state?.status && form.state.status !== "pending") continue
|
|
6
|
+
if (form.fields?.length !== 1) continue
|
|
7
|
+
const field = form.fields[0]
|
|
8
|
+
if (!field.key || field.title !== question && field.description !== question) continue
|
|
9
|
+
const option = field.options?.find((o: any) => o.label === label && typeof o.value === "string")
|
|
10
|
+
if (option) return { formID: form.id, answer: { [field.key]: option.value } }
|
|
11
|
+
}
|
|
12
|
+
}
|