opencode-goal-mode 0.1.0 → 0.2.1
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/ARCHITECTURE.md +180 -0
- package/README.md +158 -52
- package/agents/goal-api-reviewer.md +0 -2
- package/agents/goal-architect.md +0 -2
- package/agents/goal-commentator.md +0 -2
- package/agents/goal-completion-guard.md +0 -2
- package/agents/goal-coordinator.md +0 -2
- package/agents/goal-data-reviewer.md +0 -2
- package/agents/goal-deep-researcher.md +0 -2
- package/agents/goal-diff-reviewer.md +0 -2
- package/agents/goal-doc-reviewer.md +0 -2
- package/agents/goal-doc-writer.md +0 -2
- package/agents/goal-explorer.md +9 -8
- package/agents/goal-final-auditor.md +0 -2
- package/agents/goal-implementer.md +0 -2
- package/agents/goal-mapper.md +0 -2
- package/agents/goal-ops-reviewer.md +0 -2
- package/agents/goal-perf-reviewer.md +0 -2
- package/agents/goal-planner.md +10 -5
- package/agents/goal-prompt-auditor.md +0 -2
- package/agents/goal-quality-gate.md +0 -2
- package/agents/goal-researcher.md +8 -7
- package/agents/goal-reviewer.md +0 -2
- package/agents/goal-security-reviewer.md +0 -2
- package/agents/goal-test-reviewer.md +0 -2
- package/agents/goal-ux-reviewer.md +0 -2
- package/agents/goal-verifier.md +0 -2
- package/agents/goal-web-researcher.md +0 -2
- package/agents/goal.md +9 -8
- package/package.json +13 -9
- package/plugins/goal-guard/agents.js +132 -0
- package/plugins/goal-guard/completion.js +64 -0
- package/plugins/goal-guard/config.js +87 -0
- package/plugins/goal-guard/events.js +65 -0
- package/plugins/goal-guard/gates.js +85 -0
- package/plugins/goal-guard/logger.js +36 -0
- package/plugins/goal-guard/persistence.js +122 -0
- package/plugins/goal-guard/shell.js +1159 -0
- package/plugins/goal-guard/state.js +182 -0
- package/plugins/goal-guard/summary.js +46 -0
- package/plugins/goal-guard/system.js +43 -0
- package/plugins/goal-guard/tools.js +129 -0
- package/plugins/goal-guard/verdicts.js +87 -0
- package/plugins/goal-guard.js +267 -379
- package/plugins/package.json +3 -0
- package/scripts/install.mjs +170 -36
- package/docs/research-report.md +0 -37
- package/scripts/check-npm-publish-ready.mjs +0 -54
- package/scripts/validate-opencode-config.mjs +0 -82
- package/tests/agents.test.mjs +0 -70
- package/tests/commands.test.mjs +0 -23
- package/tests/helpers.mjs +0 -23
- package/tests/install.test.mjs +0 -64
- package/tests/plugin.test.mjs +0 -195
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Use in every Goal Mode review cycle to compare the original user prompt and Goal Contract against the delivered outcome with extreme strictness.
|
|
3
3
|
mode: subagent
|
|
4
|
-
model: ordis/chatgpt/gpt-5.5
|
|
5
|
-
variant: xhigh
|
|
6
4
|
temperature: 0
|
|
7
5
|
color: error
|
|
8
6
|
permission:
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Use proactively as the final quality gate before completion. Checks standards compliance, naming, style, deprecations, security hygiene, and project-specific quality rules.
|
|
3
3
|
mode: subagent
|
|
4
|
-
model: ordis/chatgpt/gpt-5.5
|
|
5
|
-
variant: xhigh
|
|
6
4
|
temperature: 0
|
|
7
5
|
color: error
|
|
8
6
|
permission:
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Use proactively for deep external/docs/schema/API research, unfamiliar dependencies, OpenCode/Claude/Codex behavior, and best-practice investigation.
|
|
3
3
|
mode: subagent
|
|
4
|
-
model: ordis/chatgpt/gpt-5.5
|
|
5
|
-
variant: high
|
|
6
4
|
color: info
|
|
7
5
|
permission:
|
|
8
6
|
read: allow
|
|
@@ -26,9 +24,12 @@ permission:
|
|
|
26
24
|
|
|
27
25
|
You are a deep research agent for Goal Mode. Prefer authoritative docs, schemas, source repos, changelogs, and API references. Do not modify files.
|
|
28
26
|
|
|
29
|
-
|
|
27
|
+
Discipline: return distilled findings, not raw dumps. Do not paste long quotes or full pages — summarize and cite. Every non-obvious claim must carry a source.
|
|
30
28
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
-
|
|
34
|
-
-
|
|
29
|
+
Final format (return only these sections):
|
|
30
|
+
|
|
31
|
+
- Key facts: each with the source it came from.
|
|
32
|
+
- Risks or caveats: with severity and why it matters.
|
|
33
|
+
- Recommended implementation approach: concrete and actionable.
|
|
34
|
+
- Confidence: high/medium/low, and what would raise it.
|
|
35
|
+
- Sources checked: URLs or `path:line`.
|
package/agents/goal-reviewer.md
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Use proactively after implementation for extremely strict correctness, completeness, regression, maintainability, and acceptance-criteria review.
|
|
3
3
|
mode: subagent
|
|
4
|
-
model: ordis/chatgpt/gpt-5.5
|
|
5
|
-
variant: xhigh
|
|
6
4
|
temperature: 0
|
|
7
5
|
color: error
|
|
8
6
|
permission:
|
package/agents/goal-verifier.md
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Use proactively for fast web search, page fetching, link summarization, trend checks, package docs, error message lookup, and quick external context without deep analysis.
|
|
3
3
|
mode: subagent
|
|
4
|
-
model: ordis/chatgpt/gpt-5.5
|
|
5
|
-
variant: high
|
|
6
4
|
temperature: 0
|
|
7
5
|
color: info
|
|
8
6
|
permission:
|
package/agents/goal.md
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Goal mode for end-to-end autonomous delivery with strict subagent research, implementation ownership, verification, and repeated review cycles until the user's goal is actually complete.
|
|
3
3
|
mode: primary
|
|
4
|
-
model: ordis/chatgpt/gpt-5.5
|
|
5
|
-
variant: xhigh
|
|
6
4
|
color: error
|
|
7
5
|
permission:
|
|
8
6
|
read: allow
|
|
@@ -40,10 +38,6 @@ permission:
|
|
|
40
38
|
doom_loop: allow
|
|
41
39
|
skill: allow
|
|
42
40
|
---
|
|
43
|
-
ext_mcp_server_trust:
|
|
44
|
-
- github
|
|
45
|
-
- browser_automation
|
|
46
|
-
- mcp_time
|
|
47
41
|
|
|
48
42
|
You are Goal Mode, an uncompromising autonomous delivery agent. Your job is to finish the user's stated goal, not merely make progress. You have full tool access and must use it responsibly, persistently, and with extreme discipline.
|
|
49
43
|
|
|
@@ -68,7 +62,6 @@ Delegation rules:
|
|
|
68
62
|
- Use `goal-commentator` for improving code comments and annotations.
|
|
69
63
|
- Use `goal-explorer` and `goal-researcher` for local file discovery and dependency research.
|
|
70
64
|
- Use `goal-implementer` for bounded implementation subtasks when explicit delegation is safer.
|
|
71
|
-
- Use `goal-coordinator` for managing multiple parallel deliverable streams in place of the unavailable `goals` tool.
|
|
72
65
|
- Use `goal-reviewer` for strict overall correctness and acceptance review.
|
|
73
66
|
- Use `goal-diff-reviewer` for exact file/code/config diff review.
|
|
74
67
|
- Use `goal-verifier` for running real verification commands and summarizing evidence.
|
|
@@ -76,7 +69,8 @@ Delegation rules:
|
|
|
76
69
|
- Use `goal-security-reviewer` for auth, secrets, permissions, network exposure, shell, and destructive risk.
|
|
77
70
|
- Use `goal-ux-reviewer` for UI, workflow, usability, and accessibility.
|
|
78
71
|
- Use `goal-doc-reviewer` for documentation quality and accuracy.
|
|
79
|
-
- Use `goal-ops-reviewer` for operational, restart, migration, and config-time changes.
|
|
72
|
+
- Use `goal-ops-reviewer` for operational, restart, migration, and config-time changes. When a change is both a security risk and an operational change, run `goal-security-reviewer` for the threat surface and `goal-ops-reviewer` for the rollout/rollback path; they do not substitute for each other.
|
|
73
|
+
- Use `goal-completion-guard` as a fast pre-flight check that every required gate has a fresh PASS before you invoke the final auditor.
|
|
80
74
|
- Use `goal-final-auditor` as the last gate before `Goal Completed`.
|
|
81
75
|
|
|
82
76
|
Required internal artifacts:
|
|
@@ -89,6 +83,13 @@ Required internal artifacts:
|
|
|
89
83
|
- Review cycles: N: explicit count of review iterations performed.
|
|
90
84
|
- Completion Gate: all required reviewers PASS after the latest edit and latest verification.
|
|
91
85
|
|
|
86
|
+
Guard tools (provided by the goal-guard plugin):
|
|
87
|
+
|
|
88
|
+
- Call `goal_contract` once the Goal Contract is settled. This activates strict enforcement and tells the guard which specialist review gates your goal requires (security, data, api, perf, etc., inferred from the contract text).
|
|
89
|
+
- Call `goal_evidence` after each meaningful verification run to record the command and result in the Verification Ledger.
|
|
90
|
+
- Call `goal_status` whenever you are unsure what the guard currently requires; it returns the authoritative list of passing, missing, and stale gates and whether completion is allowed. Trust it over your own recollection.
|
|
91
|
+
- The guard injects a live state block into your context each turn and will rewrite a premature `Goal Completed` into `Goal Not Completed` with the missing gates. Use `goal_status` to avoid that rather than guessing.
|
|
92
|
+
|
|
92
93
|
Context discipline:
|
|
93
94
|
|
|
94
95
|
- Do not fill the main thread with broad search logs, large file summaries, research dumps, or exploratory dead ends.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-goal-mode",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Strict Goal Mode agents, commands, and guard plugin for OpenCode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -13,19 +13,22 @@
|
|
|
13
13
|
"files": [
|
|
14
14
|
"agents/",
|
|
15
15
|
"commands/",
|
|
16
|
-
"docs/",
|
|
17
16
|
"plugins/",
|
|
18
|
-
"scripts/",
|
|
19
|
-
"
|
|
17
|
+
"scripts/install.mjs",
|
|
18
|
+
"ARCHITECTURE.md",
|
|
20
19
|
"LICENSE",
|
|
21
20
|
"README.md"
|
|
22
21
|
],
|
|
23
22
|
"scripts": {
|
|
24
23
|
"test": "node --test",
|
|
25
|
-
"test:
|
|
26
|
-
"test:
|
|
27
|
-
"test:plugin": "node tests/plugin.test.mjs",
|
|
28
|
-
"test:
|
|
24
|
+
"test:coverage": "node --experimental-test-coverage --test",
|
|
25
|
+
"test:shell": "node --test tests/shell.test.mjs tests/shell.property.test.mjs",
|
|
26
|
+
"test:plugin": "node --test tests/plugin.test.mjs tests/integration.test.mjs",
|
|
27
|
+
"test:unit": "node --test tests/state.test.mjs tests/gates.test.mjs tests/verdicts.test.mjs tests/config.test.mjs tests/persistence.test.mjs",
|
|
28
|
+
"test:agents": "node --test tests/agents.test.mjs tests/commands.test.mjs",
|
|
29
|
+
"test:install": "node --test tests/install.test.mjs",
|
|
30
|
+
"bench": "node benchmarks/run.mjs",
|
|
31
|
+
"bench:compare": "node benchmarks/comparison.mjs",
|
|
29
32
|
"pack:check": "npm pack --dry-run",
|
|
30
33
|
"audit": "npm audit --audit-level=moderate",
|
|
31
34
|
"publish:check": "node scripts/check-npm-publish-ready.mjs",
|
|
@@ -56,6 +59,7 @@
|
|
|
56
59
|
"registry": "https://registry.npmjs.org/"
|
|
57
60
|
},
|
|
58
61
|
"devDependencies": {
|
|
59
|
-
"@opencode-ai/plugin": "1.15.13"
|
|
62
|
+
"@opencode-ai/plugin": "1.15.13",
|
|
63
|
+
"fast-check": "^4.8.0"
|
|
60
64
|
}
|
|
61
65
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical agent identities and gating maps for Goal Mode.
|
|
3
|
+
*
|
|
4
|
+
* Keeping these in one module means the plugin, the system-prompt injector and
|
|
5
|
+
* the tests all agree on exactly which agents exist, which are review gates,
|
|
6
|
+
* and which contextual keywords pull in which specialist reviewer.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Read-only review/verification gates that emit `Verdict: PASS|FAIL`. */
|
|
10
|
+
export const REVIEW_AGENTS = Object.freeze([
|
|
11
|
+
"goal-reviewer",
|
|
12
|
+
"goal-prompt-auditor",
|
|
13
|
+
"goal-diff-reviewer",
|
|
14
|
+
"goal-verifier",
|
|
15
|
+
"goal-test-reviewer",
|
|
16
|
+
"goal-security-reviewer",
|
|
17
|
+
"goal-ux-reviewer",
|
|
18
|
+
"goal-ops-reviewer",
|
|
19
|
+
"goal-doc-reviewer",
|
|
20
|
+
"goal-final-auditor",
|
|
21
|
+
"goal-completion-guard",
|
|
22
|
+
"goal-api-reviewer",
|
|
23
|
+
"goal-data-reviewer",
|
|
24
|
+
"goal-perf-reviewer",
|
|
25
|
+
"goal-quality-gate",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/** Every agent that belongs to Goal Mode (primary + workers + reviewers). */
|
|
29
|
+
export const GOAL_AGENTS = Object.freeze([
|
|
30
|
+
"goal",
|
|
31
|
+
"goal-implementer",
|
|
32
|
+
"goal-explorer",
|
|
33
|
+
"goal-researcher",
|
|
34
|
+
"goal-deep-researcher",
|
|
35
|
+
"goal-web-researcher",
|
|
36
|
+
"goal-architect",
|
|
37
|
+
"goal-mapper",
|
|
38
|
+
"goal-planner",
|
|
39
|
+
"goal-coordinator",
|
|
40
|
+
"goal-doc-writer",
|
|
41
|
+
"goal-commentator",
|
|
42
|
+
...REVIEW_AGENTS,
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
const REVIEW_SET = new Set(REVIEW_AGENTS);
|
|
46
|
+
const GOAL_SET = new Set(GOAL_AGENTS);
|
|
47
|
+
|
|
48
|
+
export function isReviewAgent(name) {
|
|
49
|
+
return REVIEW_SET.has(String(name || ""));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isGoalAgent(name) {
|
|
53
|
+
return GOAL_SET.has(String(name || ""));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The primary Goal Mode agent. Only this agent's sessions are "goal sessions"
|
|
57
|
+
* the guard polices for completion — review/worker subagents run in their own
|
|
58
|
+
* child sessions and must not be activated (that would pollute attribution and
|
|
59
|
+
* the active-session population). */
|
|
60
|
+
export const PRIMARY_AGENT = "goal";
|
|
61
|
+
|
|
62
|
+
export function isPrimaryAgent(name) {
|
|
63
|
+
return String(name || "") === PRIMARY_AGENT;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Reviewers that always run for any meaningful goal. */
|
|
67
|
+
export const BASE_GATES = Object.freeze([
|
|
68
|
+
"goal-prompt-auditor",
|
|
69
|
+
"goal-reviewer",
|
|
70
|
+
"goal-diff-reviewer",
|
|
71
|
+
"goal-verifier",
|
|
72
|
+
"goal-final-auditor",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Keyword → specialist reviewer. When the captured goal text or the set of
|
|
77
|
+
* changed files mentions one of these keywords, the corresponding reviewer
|
|
78
|
+
* becomes a required gate. Keys are matched as whole words against lowercased
|
|
79
|
+
* text so that `api` does not match `capital`.
|
|
80
|
+
*/
|
|
81
|
+
export const CONTEXTUAL_GATES = Object.freeze({
|
|
82
|
+
security: "goal-security-reviewer",
|
|
83
|
+
secure: "goal-security-reviewer",
|
|
84
|
+
vulnerability: "goal-security-reviewer",
|
|
85
|
+
secret: "goal-security-reviewer",
|
|
86
|
+
secrets: "goal-security-reviewer",
|
|
87
|
+
password: "goal-security-reviewer",
|
|
88
|
+
credential: "goal-security-reviewer",
|
|
89
|
+
permission: "goal-security-reviewer",
|
|
90
|
+
permissions: "goal-security-reviewer",
|
|
91
|
+
auth: "goal-security-reviewer",
|
|
92
|
+
authentication: "goal-security-reviewer",
|
|
93
|
+
token: "goal-security-reviewer",
|
|
94
|
+
shell: "goal-security-reviewer",
|
|
95
|
+
test: "goal-test-reviewer",
|
|
96
|
+
tests: "goal-test-reviewer",
|
|
97
|
+
coverage: "goal-test-reviewer",
|
|
98
|
+
spec: "goal-test-reviewer",
|
|
99
|
+
ops: "goal-ops-reviewer",
|
|
100
|
+
restart: "goal-ops-reviewer",
|
|
101
|
+
install: "goal-ops-reviewer",
|
|
102
|
+
installer: "goal-ops-reviewer",
|
|
103
|
+
deploy: "goal-ops-reviewer",
|
|
104
|
+
deployment: "goal-ops-reviewer",
|
|
105
|
+
rollback: "goal-ops-reviewer",
|
|
106
|
+
api: "goal-api-reviewer",
|
|
107
|
+
endpoint: "goal-api-reviewer",
|
|
108
|
+
endpoints: "goal-api-reviewer",
|
|
109
|
+
schema: "goal-api-reviewer",
|
|
110
|
+
data: "goal-data-reviewer",
|
|
111
|
+
database: "goal-data-reviewer",
|
|
112
|
+
migration: "goal-data-reviewer",
|
|
113
|
+
migrations: "goal-data-reviewer",
|
|
114
|
+
sql: "goal-data-reviewer",
|
|
115
|
+
performance: "goal-perf-reviewer",
|
|
116
|
+
perf: "goal-perf-reviewer",
|
|
117
|
+
latency: "goal-perf-reviewer",
|
|
118
|
+
throughput: "goal-perf-reviewer",
|
|
119
|
+
scalability: "goal-perf-reviewer",
|
|
120
|
+
ux: "goal-ux-reviewer",
|
|
121
|
+
ui: "goal-ux-reviewer",
|
|
122
|
+
accessibility: "goal-ux-reviewer",
|
|
123
|
+
usability: "goal-ux-reviewer",
|
|
124
|
+
docs: "goal-doc-reviewer",
|
|
125
|
+
documentation: "goal-doc-reviewer",
|
|
126
|
+
readme: "goal-doc-reviewer",
|
|
127
|
+
quality: "goal-quality-gate",
|
|
128
|
+
standards: "goal-quality-gate",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/** The reviewer that, when it returns a verdict, closes one review cycle. */
|
|
132
|
+
export const CYCLE_CLOSING_AGENT = "goal-final-auditor";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Completion-claim enforcement.
|
|
3
|
+
*
|
|
4
|
+
* Evaluates a finished assistant message that claims `Goal Completed` and
|
|
5
|
+
* decides whether the claim is allowed. The rewrite is only applied to ACTIVE
|
|
6
|
+
* goal sessions, so an unrelated assistant turn that merely quotes the phrase
|
|
7
|
+
* in a non-goal session is never corrupted (a bug in the original).
|
|
8
|
+
*
|
|
9
|
+
* A claim is rejected when any of the following hold:
|
|
10
|
+
* - the required `Review cycles: N` line is missing;
|
|
11
|
+
* - no review cycle has been recorded (N must be > 0);
|
|
12
|
+
* - the claimed N does not match the recorded review-cycle count;
|
|
13
|
+
* - required review gates are missing or stale for the current state.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { missingGates, completionAllowed } from "./gates.js";
|
|
17
|
+
import { summarizeState } from "./summary.js";
|
|
18
|
+
|
|
19
|
+
const CYCLES_RE = /Review cycles:\s*(\d+)/i;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @returns {{ blocked: boolean, reason?: string, replacement?: string, claimedCycles?: number }}
|
|
23
|
+
*/
|
|
24
|
+
export function evaluateCompletionClaim(state, config, text) {
|
|
25
|
+
const marker = config.completionMarker || "Goal Completed";
|
|
26
|
+
const escaped = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
27
|
+
// The completion contract requires the message to START with the marker (the
|
|
28
|
+
// final response begins with "Goal Completed"). Anchor to the first non-space
|
|
29
|
+
// of the message or of any line, so a mid-sentence mention is not policed.
|
|
30
|
+
const markerRe = new RegExp(`^[\\s>*_#-]*${escaped}`, "im");
|
|
31
|
+
|
|
32
|
+
if (!text || !markerRe.test(text)) return { blocked: false };
|
|
33
|
+
// Only police active goal sessions.
|
|
34
|
+
if (!state.active) return { blocked: false };
|
|
35
|
+
|
|
36
|
+
const match = text.match(CYCLES_RE);
|
|
37
|
+
const claimedCycles = match ? Number.parseInt(match[1], 10) : -1;
|
|
38
|
+
const summary = summarizeState(state, config);
|
|
39
|
+
|
|
40
|
+
let reason = null;
|
|
41
|
+
if (claimedCycles < 0) {
|
|
42
|
+
reason = "missing required Review cycles line";
|
|
43
|
+
} else if (state.reviewCycles === 0) {
|
|
44
|
+
reason = "no review cycles recorded";
|
|
45
|
+
} else if (claimedCycles !== state.reviewCycles) {
|
|
46
|
+
reason = `claimed review cycles (${claimedCycles}) do not match recorded review cycles (${state.reviewCycles})`;
|
|
47
|
+
} else if (!completionAllowed(state, config)) {
|
|
48
|
+
const missing = missingGates(state, config).join(", ");
|
|
49
|
+
reason = `required review gates are missing or stale (${missing || "goal session not active"})`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!reason) return { blocked: false, claimedCycles };
|
|
53
|
+
|
|
54
|
+
const blockedMarker = config.blockedMarker || "Goal Not Completed";
|
|
55
|
+
// Rewrite the SAME anchored line that triggered detection (preserving its
|
|
56
|
+
// leading markdown prefix), not merely the first occurrence of the phrase —
|
|
57
|
+
// otherwise an unrelated earlier mention would be mangled while the real
|
|
58
|
+
// completion-claim heading stayed unflipped.
|
|
59
|
+
const markerLineRe = new RegExp(`^([\\s>*_#-]*)${escaped}`, "im");
|
|
60
|
+
const replacement =
|
|
61
|
+
text.replace(markerLineRe, (_m, prefix) => `${prefix}${blockedMarker}`) +
|
|
62
|
+
`\n\nGoal Guard blocked completion: ${reason}. State: ${summary}`;
|
|
63
|
+
return { blocked: true, reason, replacement, claimedCycles };
|
|
64
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration resolution for the guard.
|
|
3
|
+
*
|
|
4
|
+
* Precedence (lowest → highest): built-in defaults < environment variables <
|
|
5
|
+
* the plugin `options` object passed from opencode.json's `["spec", {…}]`
|
|
6
|
+
* form. This lets the guard be tuned per project without code changes, while
|
|
7
|
+
* still working with zero configuration when auto-discovered.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_CONFIG = Object.freeze({
|
|
11
|
+
/** Block destructive shell commands before execution (throw in tool.execute.before). */
|
|
12
|
+
blockDestructive: true,
|
|
13
|
+
/** Also block remote-code-execution pipelines (curl | sh). */
|
|
14
|
+
blockNetworkExec: true,
|
|
15
|
+
/** Rewrite premature `Goal Completed` claims in experimental.text.complete. */
|
|
16
|
+
enforceCompletion: true,
|
|
17
|
+
/** Inject a live Goal Guard state block into the system prompt. */
|
|
18
|
+
injectSystemState: true,
|
|
19
|
+
/** Persist guard state to disk so it survives OpenCode restarts. */
|
|
20
|
+
persist: true,
|
|
21
|
+
/** Require the contextual specialist gates derived from goal text / changed files. */
|
|
22
|
+
contextualGates: true,
|
|
23
|
+
/** Maximum tracked sessions before LRU eviction. */
|
|
24
|
+
maxSessions: 200,
|
|
25
|
+
/** Idle TTL (ms) after which a session's state may be dropped. 0 disables TTL. */
|
|
26
|
+
sessionTtlMs: 24 * 60 * 60 * 1000,
|
|
27
|
+
/** Emit a TUI toast when completion is blocked. */
|
|
28
|
+
toastOnBlock: true,
|
|
29
|
+
/** Phrase that, at the start of an assistant message, claims completion. */
|
|
30
|
+
completionMarker: "Goal Completed",
|
|
31
|
+
/** Replacement marker when completion is blocked. */
|
|
32
|
+
blockedMarker: "Goal Not Completed",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function coerceBool(value, fallback) {
|
|
36
|
+
if (value === undefined || value === null) return fallback;
|
|
37
|
+
if (typeof value === "boolean") return value;
|
|
38
|
+
const s = String(value).trim().toLowerCase();
|
|
39
|
+
if (["1", "true", "yes", "on"].includes(s)) return true;
|
|
40
|
+
if (["0", "false", "no", "off"].includes(s)) return false;
|
|
41
|
+
return fallback;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function coerceInt(value, fallback) {
|
|
45
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
46
|
+
const n = Number.parseInt(String(value), 10);
|
|
47
|
+
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function fromEnv(env) {
|
|
51
|
+
const out = {};
|
|
52
|
+
const map = {
|
|
53
|
+
GOAL_GUARD_BLOCK_DESTRUCTIVE: ["blockDestructive", coerceBool],
|
|
54
|
+
GOAL_GUARD_BLOCK_NETWORK_EXEC: ["blockNetworkExec", coerceBool],
|
|
55
|
+
GOAL_GUARD_ENFORCE_COMPLETION: ["enforceCompletion", coerceBool],
|
|
56
|
+
GOAL_GUARD_INJECT_SYSTEM_STATE: ["injectSystemState", coerceBool],
|
|
57
|
+
GOAL_GUARD_PERSIST: ["persist", coerceBool],
|
|
58
|
+
GOAL_GUARD_CONTEXTUAL_GATES: ["contextualGates", coerceBool],
|
|
59
|
+
GOAL_GUARD_MAX_SESSIONS: ["maxSessions", coerceInt],
|
|
60
|
+
GOAL_GUARD_SESSION_TTL_MS: ["sessionTtlMs", coerceInt],
|
|
61
|
+
GOAL_GUARD_TOAST_ON_BLOCK: ["toastOnBlock", coerceBool],
|
|
62
|
+
};
|
|
63
|
+
for (const [key, [field, coerce]] of Object.entries(map)) {
|
|
64
|
+
if (env[key] !== undefined) out[field] = coerce(env[key], DEFAULT_CONFIG[field]);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {Record<string, unknown>|undefined} options Plugin options (2nd factory arg).
|
|
71
|
+
* @param {Record<string, string|undefined>} [env] Environment (defaults to process.env).
|
|
72
|
+
* @returns {typeof DEFAULT_CONFIG}
|
|
73
|
+
*/
|
|
74
|
+
export function resolveConfig(options, env = process.env) {
|
|
75
|
+
const envConfig = fromEnv(env || {});
|
|
76
|
+
const opts = options && typeof options === "object" ? options : {};
|
|
77
|
+
const merged = { ...DEFAULT_CONFIG, ...envConfig };
|
|
78
|
+
|
|
79
|
+
for (const key of Object.keys(DEFAULT_CONFIG)) {
|
|
80
|
+
if (opts[key] === undefined) continue;
|
|
81
|
+
const def = DEFAULT_CONFIG[key];
|
|
82
|
+
if (typeof def === "boolean") merged[key] = coerceBool(opts[key], merged[key]);
|
|
83
|
+
else if (typeof def === "number") merged[key] = coerceInt(opts[key], merged[key]);
|
|
84
|
+
else merged[key] = opts[key];
|
|
85
|
+
}
|
|
86
|
+
return Object.freeze(merged);
|
|
87
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State-mutation primitives shared by the plugin hooks and the custom tools, so
|
|
3
|
+
* that an edit recorded via the `edit` tool, a bash mutation, a `file.edited`
|
|
4
|
+
* event, and a `goal_evidence` tool call all advance the same monotonic seq and
|
|
5
|
+
* dirty bookkeeping in exactly one place.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { CYCLE_CLOSING_AGENT } from "./agents.js";
|
|
9
|
+
import { gatePassedFresh, completionAllowed } from "./gates.js";
|
|
10
|
+
|
|
11
|
+
function trim(arr, max) {
|
|
12
|
+
if (arr.length > max) arr.splice(0, arr.length - max);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function markEdit(store, state, reason) {
|
|
16
|
+
const at = store.nowIso();
|
|
17
|
+
state.active = true;
|
|
18
|
+
state.dirty = true;
|
|
19
|
+
state.lastEditAt = at;
|
|
20
|
+
state.lastEditSeq = store.nextSeq();
|
|
21
|
+
state.dirtyReasons.push(reason || `edit at ${at}`);
|
|
22
|
+
trim(state.dirtyReasons, 50);
|
|
23
|
+
state.updatedAt = at;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function markVerification(store, state) {
|
|
27
|
+
const at = store.nowIso();
|
|
28
|
+
state.verificationSeen = true;
|
|
29
|
+
state.lastVerificationAt = at;
|
|
30
|
+
state.lastVerificationSeq = store.nextSeq();
|
|
31
|
+
state.updatedAt = at;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function markFileChanged(store, state, file) {
|
|
35
|
+
const name = String(file || "").trim();
|
|
36
|
+
if (!name) return;
|
|
37
|
+
if (!state.changedFiles.includes(name)) state.changedFiles.push(name);
|
|
38
|
+
trim(state.changedFiles, 200);
|
|
39
|
+
markEdit(store, state, `file edited: ${name}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function recordEvidence(store, state, command, result, criteria) {
|
|
43
|
+
const at = store.nowIso();
|
|
44
|
+
state.evidence.push({
|
|
45
|
+
command: String(command || ""),
|
|
46
|
+
result: String(result || ""),
|
|
47
|
+
criteria: Array.isArray(criteria) ? criteria.slice(0, 50) : [],
|
|
48
|
+
at,
|
|
49
|
+
});
|
|
50
|
+
trim(state.evidence, 100);
|
|
51
|
+
markVerification(store, state);
|
|
52
|
+
state.updatedAt = at;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* When the cycle-closing auditor passes and all gates are fresh, the candidate
|
|
57
|
+
* is genuinely clean: clear the dirty flag so the next answer can complete.
|
|
58
|
+
*/
|
|
59
|
+
export function maybeClearDirtyOnFinalPass(state, config) {
|
|
60
|
+
if (!gatePassedFresh(state, CYCLE_CLOSING_AGENT)) return false;
|
|
61
|
+
if (!completionAllowed(state, config)) return false;
|
|
62
|
+
state.dirty = false;
|
|
63
|
+
state.dirtyReasons = [];
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Required-gate computation and freshness.
|
|
3
|
+
*
|
|
4
|
+
* Fixes versus the original:
|
|
5
|
+
* - Contextual gates actually fire. The previous code computed required gates
|
|
6
|
+
* from `dirtyReasons` only (which never contain meaningful keywords), so the
|
|
7
|
+
* specialist reviewers were dead code. Here gates are derived from the
|
|
8
|
+
* captured goal text, the recorded Goal Contract, and the set of changed
|
|
9
|
+
* files.
|
|
10
|
+
* - Keyword matching is whole-word (token set), so `api` no longer matches
|
|
11
|
+
* inside `capital` and a fingerprint hex can't accidentally pull in a gate.
|
|
12
|
+
* - Freshness uses the monotonic seq counter and is invalidated by EDITS only.
|
|
13
|
+
* Re-running verification after a clean review no longer re-opens the gates.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { BASE_GATES, CONTEXTUAL_GATES } from "./agents.js";
|
|
17
|
+
|
|
18
|
+
function contractText(contract) {
|
|
19
|
+
if (!contract || typeof contract !== "object") return "";
|
|
20
|
+
const parts = [contract.original];
|
|
21
|
+
for (const field of ["requirements", "inferred", "nonGoals", "acceptanceCriteria"]) {
|
|
22
|
+
if (Array.isArray(contract[field])) parts.push(contract[field].join(" "));
|
|
23
|
+
}
|
|
24
|
+
return parts.filter(Boolean).join(" ");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Tokenize text into a lowercase whole-word set. */
|
|
28
|
+
function wordSet(text) {
|
|
29
|
+
const set = new Set();
|
|
30
|
+
for (const w of String(text || "").toLowerCase().split(/[^a-z0-9]+/)) {
|
|
31
|
+
if (w) set.add(w);
|
|
32
|
+
}
|
|
33
|
+
return set;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The specialist reviewers implied by the current goal text/contract/files. */
|
|
37
|
+
export function contextualGatesFor(state) {
|
|
38
|
+
const found = [];
|
|
39
|
+
const text = [state.goalText, contractText(state.contract), (state.changedFiles || []).join(" ")].join(" ");
|
|
40
|
+
const words = wordSet(text);
|
|
41
|
+
for (const [keyword, agent] of Object.entries(CONTEXTUAL_GATES)) {
|
|
42
|
+
if (words.has(keyword) && !found.includes(agent)) found.push(agent);
|
|
43
|
+
}
|
|
44
|
+
return found;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Union the currently-implied contextual gates into the session's sticky set.
|
|
49
|
+
* Gates are sticky so a long session whose rolling goalText buffer later drops
|
|
50
|
+
* the triggering keyword does not silently lose a required specialist review.
|
|
51
|
+
*/
|
|
52
|
+
export function refreshStickyGates(state) {
|
|
53
|
+
if (!Array.isArray(state.stickyGates)) state.stickyGates = [];
|
|
54
|
+
for (const agent of contextualGatesFor(state)) {
|
|
55
|
+
if (!state.stickyGates.includes(agent)) state.stickyGates.push(agent);
|
|
56
|
+
}
|
|
57
|
+
return state.stickyGates;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The reviewers that must PASS for this state, given config. */
|
|
61
|
+
export function requiredGates(state, config) {
|
|
62
|
+
const gates = [...BASE_GATES];
|
|
63
|
+
if (!config || config.contextualGates) {
|
|
64
|
+
const contextual = new Set([...(state.stickyGates || []), ...contextualGatesFor(state)]);
|
|
65
|
+
for (const agent of contextual) {
|
|
66
|
+
if (!gates.includes(agent)) gates.push(agent);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return gates;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A gate is satisfied when its latest verdict is PASS and newer than the last edit. */
|
|
73
|
+
export function gatePassedFresh(state, agent) {
|
|
74
|
+
const v = state.latestVerdict[agent];
|
|
75
|
+
if (!v || v.verdict !== "PASS") return false;
|
|
76
|
+
return v.seq > (state.lastEditSeq || 0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function missingGates(state, config) {
|
|
80
|
+
return requiredGates(state, config).filter((agent) => !gatePassedFresh(state, agent));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function completionAllowed(state, config) {
|
|
84
|
+
return Boolean(state.active) && missingGates(state, config).length === 0;
|
|
85
|
+
}
|