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