@plainconceptsplatform/agent-harness 3.8.3 → 3.9.0-alpha.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.
Files changed (148) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +551 -551
  3. package/cli/commands/join.js +259 -259
  4. package/cli/commands/migrate.js +310 -310
  5. package/cli/commands/shared.js +27 -27
  6. package/cli/commands/single.js +79 -79
  7. package/cli/commands/update.js +121 -121
  8. package/cli/commands/wizard.js +134 -134
  9. package/cli/fragments/archive/az.md +97 -97
  10. package/cli/fragments/archive/gh.md +96 -96
  11. package/cli/fragments/archive/gl.md +96 -96
  12. package/cli/fragments/archive/none.md +75 -75
  13. package/cli/fragments/guardrails/codegraph.md +5 -5
  14. package/cli/fragments/guardrails/humanizer.md +4 -4
  15. package/cli/fragments/guardrails/memory.md +4 -4
  16. package/cli/fragments/guardrails/rtk.md +3 -3
  17. package/cli/fragments/guardrails/simple-english.md +4 -4
  18. package/cli/fragments/ops-backlog/az.md +28 -28
  19. package/cli/fragments/ops-backlog/gh.md +28 -28
  20. package/cli/fragments/ops-backlog/jira.md +28 -28
  21. package/cli/fragments/ops-review/az.md +62 -62
  22. package/cli/fragments/ops-review/gh.md +51 -51
  23. package/cli/fragments/ops-review/gl.md +55 -55
  24. package/cli/fragments/ops-ship/az.md +81 -81
  25. package/cli/fragments/ops-ship/gh.md +67 -67
  26. package/cli/fragments/ops-ship/gl.md +84 -84
  27. package/cli/index.js +116 -116
  28. package/cli/presets/agents-content.json +34 -34
  29. package/cli/presets/browser.json +10 -10
  30. package/cli/presets/clean.json +21 -21
  31. package/cli/presets/models.json +68 -68
  32. package/cli/presets/optimization.json +32 -32
  33. package/cli/presets/platforms.json +76 -76
  34. package/cli/presets/source.json +23 -23
  35. package/cli/steps/browser/index.js +52 -48
  36. package/cli/steps/clean/index.js +120 -120
  37. package/cli/steps/copy/agents.js +176 -176
  38. package/cli/steps/copy/commands.js +79 -79
  39. package/cli/steps/copy/fullstack-engineer.js +81 -89
  40. package/cli/steps/copy/index.js +88 -88
  41. package/cli/steps/copy/opencode-json.js +324 -324
  42. package/cli/steps/copy/skills.js +316 -316
  43. package/cli/steps/metadata/index.js +115 -115
  44. package/cli/steps/models/format.js +88 -88
  45. package/cli/steps/models/index.js +64 -64
  46. package/cli/steps/models/write.js +38 -38
  47. package/cli/steps/openspec/index.js +136 -136
  48. package/cli/steps/optimization/codegraph.js +141 -141
  49. package/cli/steps/optimization/detect.js +66 -66
  50. package/cli/steps/optimization/humanizer.js +17 -17
  51. package/cli/steps/optimization/index.js +158 -158
  52. package/cli/steps/optimization/memory.js +91 -91
  53. package/cli/steps/optimization/patch-guardrails.js +111 -111
  54. package/cli/steps/optimization/simple-english.js +17 -17
  55. package/cli/steps/optimization/skills-lock.js +30 -30
  56. package/cli/steps/platform/index.js +109 -109
  57. package/cli/steps/source/index.js +123 -123
  58. package/cli/utils/agent-color.js +111 -111
  59. package/cli/utils/copy.js +134 -134
  60. package/cli/utils/exec-spinner.js +47 -47
  61. package/cli/utils/exec.js +134 -134
  62. package/cli/utils/legacy-check.js +30 -30
  63. package/cli/utils/models-cache.js +58 -58
  64. package/cli/utils/models-pricing.js +42 -42
  65. package/cli/utils/paths.js +82 -82
  66. package/cli/utils/process.js +3 -3
  67. package/cli/utils/terminal.js +6 -6
  68. package/cli/utils/update-manifest.js +75 -75
  69. package/harness/.opencode/_gitignore +12 -10
  70. package/harness/.opencode/commands/init.md +5 -5
  71. package/harness/.opencode/commands/make-architecture.md +5 -5
  72. package/harness/.opencode/commands/make-design.md +5 -5
  73. package/harness/.opencode/commands/make-engineer.md +5 -5
  74. package/harness/.opencode/commands/make-guardrails.md +5 -5
  75. package/harness/.opencode/commands/make-user-model.md +5 -5
  76. package/harness/.opencode/commands/ops-backlog.md +10 -10
  77. package/harness/.opencode/commands/ops-review.md +10 -10
  78. package/harness/.opencode/commands/ops-ship.md +9 -9
  79. package/harness/.opencode/commands/plan-apply.md +9 -9
  80. package/harness/.opencode/commands/plan-archive.md +7 -7
  81. package/harness/.opencode/commands/plan-explore.md +11 -11
  82. package/harness/.opencode/commands/plan-goal.md +5 -5
  83. package/harness/.opencode/commands/plan-propose.md +11 -11
  84. package/harness/.opencode/commands/plan-quick.md +7 -7
  85. package/harness/.opencode/commands/plan-story.md +11 -11
  86. package/harness/.opencode/commands/repo-audit.md +7 -7
  87. package/harness/.opencode/commands/repo-help.md +5 -5
  88. package/harness/.opencode/commands/repo-initialize.md +5 -5
  89. package/harness/.opencode/commands/repo-onboard.md +5 -5
  90. package/harness/.opencode/commands/repo-verify.md +5 -5
  91. package/harness/.opencode/package.json +6 -5
  92. package/harness/.opencode/plugins/pc-commit-coauthor.js +41 -41
  93. package/harness/.opencode/plugins/pc-subagent-monitor.js +184 -184
  94. package/harness/.opencode/plugins/pc-subagent-tiers-core.js +337 -0
  95. package/harness/.opencode/plugins/pc-subagent-tiers.js +56 -367
  96. package/harness/.opencode/plugins/pc-system-reminders.js +424 -424
  97. package/harness/.opencode/scripts/check-diff.mjs +305 -305
  98. package/harness/.opencode/scripts/code-rules.mjs +288 -288
  99. package/harness/.opencode/scripts/harness-doctor.mjs +52 -0
  100. package/harness/.opencode/scripts/harness-doctor.test.mjs +58 -0
  101. package/harness/.opencode/scripts/platform-contract.mjs +366 -366
  102. package/harness/.opencode/scripts/sync-agents.mjs +29 -0
  103. package/harness/AGENTS.md +55 -55
  104. package/harness/ARCHITECTURE.md +13 -13
  105. package/harness/DESIGN.md +13 -13
  106. package/harness/opencode.jsonc +36 -36
  107. package/harness/openspec/config.yaml +20 -20
  108. package/harness/skills-lock.json +17 -17
  109. package/package.json +14 -12
  110. package/skills/README.md +18 -18
  111. package/skills/agent-harness-cli/SKILL.md +86 -86
  112. package/skills/browser-automation/SKILL.md +100 -72
  113. package/skills/pc-guardrails-generic/SKILL.md +53 -53
  114. package/skills/pc-guardrails-project/SKILL.md +8 -8
  115. package/skills/pc-make-architecture/SKILL.md +33 -33
  116. package/skills/pc-make-architecture/structure-template.md +64 -64
  117. package/skills/pc-make-design/SKILL.md +45 -45
  118. package/skills/pc-make-engineer/SKILL.md +59 -59
  119. package/skills/pc-make-engineer/signal-mapping.md +53 -53
  120. package/skills/pc-make-engineer/template.md +36 -36
  121. package/skills/pc-make-guardrails/SKILL.md +44 -44
  122. package/skills/pc-make-guardrails/category-reference.md +112 -112
  123. package/skills/pc-make-merge-risk-assess/SKILL.md +70 -70
  124. package/skills/pc-make-merge-risk-assess/category-reference.md +114 -114
  125. package/skills/pc-make-user-model/SKILL.md +56 -56
  126. package/skills/pc-ops-ship/SKILL.md +22 -22
  127. package/skills/pc-plan-apply/SKILL.md +90 -90
  128. package/skills/pc-plan-apply/simple-mode.md +21 -21
  129. package/skills/pc-plan-archive/SKILL.md +66 -66
  130. package/skills/pc-plan-explore/SKILL.md +21 -21
  131. package/skills/pc-plan-goal/SKILL.md +91 -91
  132. package/skills/pc-plan-goal/branching.md +39 -39
  133. package/skills/pc-plan-goal/failure-policy.md +30 -30
  134. package/skills/pc-plan-goal/output-mode.md +10 -10
  135. package/skills/pc-plan-goal/output.md +71 -71
  136. package/skills/pc-plan-propose/SKILL.md +127 -127
  137. package/skills/pc-plan-propose/task-annotation.md +39 -39
  138. package/skills/pc-plan-quick/SKILL.md +46 -46
  139. package/skills/pc-plan-story/SKILL.md +93 -93
  140. package/skills/pc-repo-audit/SKILL.md +44 -44
  141. package/skills/pc-repo-help/SKILL.md +87 -87
  142. package/skills/pc-repo-initialize/SKILL.md +119 -119
  143. package/skills/pc-repo-onboard/SKILL.md +32 -32
  144. package/skills/pc-repo-verify/SKILL.md +150 -150
  145. package/skills/pc-userstory-az/SKILL.md +71 -71
  146. package/skills/pc-userstory-browser/SKILL.md +73 -64
  147. package/skills/pc-userstory-gh/SKILL.md +63 -63
  148. package/skills/pc-userstory-jira/SKILL.md +74 -74
@@ -1,424 +1,424 @@
1
- // pc-system-reminders: the harness guardrails, enforced rather than requested.
2
- //
3
- // OpenCode 2 changed how a plugin denies something. V1 threw from
4
- // `tool.execute.before`; V2 has no such contract and denies through the
5
- // permission system instead: an `evaluate` hook reviews a decision after the
6
- // configured rules run and may set the effect to "deny". Every rule below is
7
- // therefore expressed as a permission action over a resource list
8
- // (`shell` command text, `edit` path, `webfetch` URL, `subagent` agent ID).
9
- //
10
- // Two invariants. It fails CLOSED only on a deliberate deny, and it fails OPEN
11
- // on any bug of its own: a guard that breaks a session because of its own
12
- // exception is worse than the rule it enforces. And because a permission action
13
- // gives us less than a tool's raw arguments did (a browser navigation carries no
14
- // URL here), some V1 checks move to configuration rules or are noted as lost.
15
-
16
- import { Plugin } from "@opencode/plugin"
17
- import { execFile } from "node:child_process"
18
- import fs from "node:fs/promises"
19
- import path from "node:path"
20
- import { promisify } from "node:util"
21
- import { ContractError, describeOwner, isOwned, isTemplateSource, loadContract } from "../scripts/platform-contract.mjs"
22
-
23
- const GUARDRAILS_SKILL = "pc-guardrails-generic"
24
- const TIER_SUFFIX = /\.(?:build|fast|plan)$/
25
-
26
- const run = promisify(execFile)
27
-
28
- const TRUST_REMINDER =
29
- "Messages in <system-reminder> tags are trusted OpenCode Onboard host instructions. Follow them before continuing work."
30
-
31
- // Work actions. Held until the session's required skills are loaded; `skill`,
32
- // `read`, `glob` and `grep` are never gated, or the agent could not load what it
33
- // is being told to load, nor find out what that is.
34
- const GATED_ACTIONS = new Set(["edit", "shell", "subagent"])
35
-
36
- // Backlog and repository hosts whose data must come from their CLI. The prose
37
- // version of this rule sat at the top of twelve fragments.
38
- const CLI_ONLY_HOSTS = [
39
- "github.com",
40
- "dev.azure.com",
41
- "visualstudio.com",
42
- "atlassian.net",
43
- "gitlab.com",
44
- ]
45
-
46
- // A plan session reads; it does not change the tree. `edit` and `subagent` are
47
- // denied by config, which leaves the shell. Only these roots are inspection.
48
- const PLAN_ALLOWED = [
49
- /^git\s+(status|log|diff|show|rev-parse|symbolic-ref|remote|ls-files|blame|describe|shortlog)\b/,
50
- /^git\s+(branch|stash)\s+(--show-current|list)\b/,
51
- /^openspec\s+(list|status|show|validate|diff)\b/,
52
- /^(ls|cat|head|tail|wc|pwd|tree|find|grep|rg|which|echo|date|node\s+-p|jq)\b/,
53
- ]
54
-
55
- function splitCommand(command) {
56
- // Every segment of a compound command has to pass on its own: one allowed
57
- // read followed by `&& rm -rf` is not a read.
58
- return command.split(/\|\||&&|;|\||\n/).map(part => part.trim()).filter(Boolean)
59
- }
60
-
61
- /** Mark a permission decision as denied; `message` is the reason shown. */
62
- function deny(event, rule, fix) {
63
- event.effect = "deny"
64
- event.message = `[harness] ${rule}\n${fix}`
65
- }
66
-
67
- function checkGit(event, command) {
68
- for (const segment of splitCommand(command)) {
69
- if (/^git\s+add\s+(-A\b|--all\b|\.(\s|$))/.test(segment)) {
70
- return deny(
71
- event,
72
- "`git add -A` and `git add .` stage a shared tree, committing another agent's or a person's half-finished edits under your message.",
73
- "Stage the paths you changed: `git add <path> <path>`.",
74
- )
75
- }
76
- if (/^git\s+commit\b.*\s-a\b/.test(segment) || /^git\s+commit\s+-[a-z]*a[a-z]*\b/.test(segment)) {
77
- return deny(
78
- event,
79
- "`git commit -a` stages every tracked change, including work that is not yours.",
80
- "Stage named paths first, then commit without `-a`.",
81
- )
82
- }
83
- if (/^git\s+clean\b/.test(segment) && !/\s--\s+\S/.test(segment)) {
84
- return deny(
85
- event,
86
- "`git clean` without `-- <paths>` deletes untracked files anywhere in the tree, including work nobody has committed yet.",
87
- "Scope it: `git clean -f -- <path> <path>`.",
88
- )
89
- }
90
- if (/^git\s+reset\s+--hard\b/.test(segment)) {
91
- return deny(
92
- event,
93
- "`git reset --hard` discards uncommitted work in a tree you may be sharing.",
94
- "Revert the paths you touched: `git checkout -- <path>`.",
95
- )
96
- }
97
- if (/^git\s+stash\s+(drop|clear)\b/.test(segment)) {
98
- return deny(
99
- event,
100
- "Dropping a stash destroys work that was set aside, possibly not yours.",
101
- "Leave the stash and report its `git stash list` reference.",
102
- )
103
- }
104
- if (/^git\s+(checkout|restore)\s+\.(\s|$)/.test(segment)) {
105
- return deny(
106
- event,
107
- "`git checkout .` and `git restore .` discard every uncommitted change in the tree.",
108
- "Name the paths to revert: `git checkout -- <path>`.",
109
- )
110
- }
111
- if (/^git\s+push\b/.test(segment) && /--force\b/.test(segment) && !/--force-with-lease\b/.test(segment)) {
112
- return deny(
113
- event,
114
- "`git push --force` can overwrite commits someone else pushed.",
115
- "Use `--force-with-lease`, which refuses when the remote moved.",
116
- )
117
- }
118
- }
119
- }
120
-
121
- async function checkPush(event, command, root, defaultBranch) {
122
- if (!defaultBranch) return
123
- for (const segment of splitCommand(command)) {
124
- if (!/^git\s+push\b/.test(segment)) continue
125
- // Split on delimiters rather than interpolating the branch name into a
126
- // pattern: a branch name is not a pattern, and a dynamic RegExp was both a
127
- // Semgrep finding and a hole in the guard.
128
- const named = segment.split(/[\s:]+/).includes(defaultBranch)
129
- if (named) {
130
- return deny(
131
- event,
132
- `This pushes \`${defaultBranch}\`, the default branch. The harness ships work on a branch and lets a human merge it.`,
133
- "Push your work branch instead, or open a pull request.",
134
- )
135
- }
136
- if (!/\s(origin|upstream)\b/.test(segment) || /^git\s+push\s*$/.test(segment)) {
137
- const current = await run("git", ["branch", "--show-current"], { cwd: root })
138
- .then(result => result.stdout.trim())
139
- .catch(() => "")
140
- if (current && current === defaultBranch) {
141
- return deny(
142
- event,
143
- `You are on \`${defaultBranch}\`, the default branch, and this pushes it.`,
144
- "Move the work to a branch first: `git switch -c feature/<slug>`.",
145
- )
146
- }
147
- }
148
- }
149
- }
150
-
151
- // `/tmp/gh-aw/` is the agent workflow runtime's own working directory, not agent
152
- // scratch. GitHub Agentic Workflows stages the issue context there and the
153
- // worker prompts name those paths, so writes inside it are expected.
154
- function checkScratch(event, command) {
155
- const writesSomewhere = /(>>?|\btee\b|\bcp\b|\bmv\b|\bmkdir\b|\btouch\b|\bdd\b)/.test(command)
156
- const outsideRepo = /(\/tmp\/(?!gh-aw\/)|\$TMPDIR|\$TEMP\b|%TEMP%|\bmktemp\b)/.test(command)
157
- if (writesSomewhere && outsideRepo) {
158
- return deny(
159
- event,
160
- "Scratch files belong inside the repository, where the next step and the next agent can still find them.",
161
- "Write under `$REPO_ROOT/.opencode/.tmp/`, or the workflow runtime's own `/tmp/gh-aw/`.",
162
- )
163
- }
164
- }
165
-
166
- function checkWritePath(event, target, root) {
167
- if (typeof target !== "string" || !target) return
168
- if (!path.isAbsolute(target)) return
169
- // Windows hands back mixed drive-letter case, so compare case-insensitively
170
- // there and exactly everywhere else.
171
- const normalize = value => (process.platform === "win32" ? path.resolve(value).toLowerCase() : path.resolve(value))
172
- const resolved = normalize(target)
173
- const base = normalize(root)
174
- if (resolved === base || resolved.startsWith(base + path.sep)) return
175
- return deny(
176
- event,
177
- `\`${target}\` is outside the repository, so nothing else in the run can see it and nobody will clean it up.`,
178
- "Write under `$REPO_ROOT/.opencode/.tmp/` instead.",
179
- )
180
- }
181
-
182
- const repoRelative = (target, root) =>
183
- (path.isAbsolute(target) ? path.relative(root, target) : target).replace(/\\/g, "/").replace(/^\.\//, "")
184
-
185
- function isOwnedTarget(target, root, contract) {
186
- if (typeof target !== "string" || !target) return false
187
- const relative = repoRelative(target, root)
188
- return !relative.startsWith("..") && isOwned(contract, relative)
189
- }
190
-
191
- // A template sync in progress has changed its marker file, and resolving its conflicts edits owned files.
192
- async function syncInProgress(root, marker) {
193
- if (!marker) return false
194
- try {
195
- const { stdout } = await run("git", ["status", "--porcelain", "--", marker], { cwd: root })
196
- return stdout.trim() !== ""
197
- } catch {
198
- return false
199
- }
200
- }
201
-
202
- async function checkPlatform(event, root) {
203
- let contract
204
- try {
205
- contract = loadContract(root)
206
- } catch (error) {
207
- if (!(error instanceof ContractError)) throw error
208
- // An unreadable contract must not pass every edit: only the files that fix it stay editable.
209
- if (event.resources.every(target => repoRelative(String(target), root).startsWith(".platform/"))) return
210
- return deny(event, error.message, "Fix that file before editing anything else: until it reads, nothing can tell which files the template owns.")
211
- }
212
- const owned = contract ? event.resources.filter(target => isOwnedTarget(target, root, contract)) : []
213
- if (!owned.length || isTemplateSource(root, contract) || await syncInProgress(root, contract.sync.marker)) return
214
- const { owner, fix } = describeOwner(contract)
215
- return deny(event, `\`${owned[0]}\` is ${owner}.`, fix)
216
- }
217
-
218
- function checkHost(event, url) {
219
- if (typeof url !== "string" || !url) return
220
- let host = ""
221
- try {
222
- host = new URL(url).hostname.toLowerCase()
223
- } catch {
224
- return
225
- }
226
- const cliOnly = CLI_ONLY_HOSTS.find(candidate => host === candidate || host.endsWith(`.${candidate}`))
227
- if (cliOnly) {
228
- return deny(
229
- event,
230
- `${cliOnly} data must come from its CLI (\`gh\`, \`az\`, \`glab\`, \`acli\`), which is authenticated and returns structured output. A page fetch returns whatever HTML the browser would see.`,
231
- "Use the platform CLI. If it is unavailable, report that as a blocker.",
232
- )
233
- }
234
- }
235
-
236
- function checkPlanReadOnly(event, command) {
237
- for (const segment of splitCommand(command)) {
238
- // `echo` and `cat` are inspection right up to the point a redirect turns
239
- // them into a write, so the redirect is checked before the allowlist.
240
- if (/(^|\s)>>?\s*\S/.test(segment) || /\btee\b/.test(segment)) {
241
- return deny(
242
- event,
243
- "The plan agent reads; a redirect writes to the tree.",
244
- "Switch to the build agent to make changes.",
245
- )
246
- }
247
- if (PLAN_ALLOWED.some(pattern => pattern.test(segment))) continue
248
- return deny(
249
- event,
250
- `The plan agent reads; it does not change the tree, and \`${segment.split(/\s+/)[0]}\` is not one of its inspection commands.`,
251
- "Switch to the build agent to make changes.",
252
- )
253
- }
254
- }
255
-
256
- function skillNames(content) {
257
- const abilities = content.match(/^## Abilities\s*\n([\s\S]*?)(?=^## |\s*$)/m)?.[1] ?? ""
258
- return [...abilities.matchAll(/@([a-z0-9][a-z0-9-]*)/gi)].map(match => match[1])
259
- }
260
-
261
- function transitiveSkillNames(content) {
262
- return [...content.matchAll(/skill\(["`]([a-z0-9][a-z0-9-]*)["`]\)/gi)].map(match => match[1])
263
- }
264
-
265
- async function readFile(filePath) {
266
- try {
267
- return await fs.readFile(filePath, "utf-8")
268
- } catch {
269
- return ""
270
- }
271
- }
272
-
273
- async function requiredSkills(directory, agent) {
274
- const baseAgent = (agent ?? "").replace(TIER_SUFFIX, "")
275
- const agentPath = path.join(directory, ".opencode", "agents", `${baseAgent}.md`)
276
- const guardrailsPath = path.join(directory, ".agents", "skills", GUARDRAILS_SKILL, "SKILL.md")
277
- const [agentContent, guardrailsContent] = await Promise.all([readFile(agentPath), readFile(guardrailsPath)])
278
- return new Set([GUARDRAILS_SKILL, ...skillNames(agentContent), ...transitiveSkillNames(guardrailsContent)])
279
- }
280
-
281
- // Only skills that exist on disk can gate work. An `@skill` reference to
282
- // something uninstalled would otherwise be unloadable and deadlock the worker,
283
- // which is a worse failure than the dangling reference itself.
284
- async function installedSkills(directory, names) {
285
- const present = new Set()
286
- await Promise.all([...names].map(async name => {
287
- try {
288
- await fs.access(path.join(directory, ".agents", "skills", name, "SKILL.md"))
289
- present.add(name)
290
- } catch {
291
- // not installed; the reminder still names it, the gate ignores it
292
- }
293
- }))
294
- return present
295
- }
296
-
297
- async function detectDefaultBranch(root) {
298
- const fromRemote = await run("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd: root })
299
- .then(result => result.stdout.trim().replace(/^origin\//, ""))
300
- .catch(() => "")
301
- if (fromRemote) return fromRemote
302
- return run("git", ["config", "--get", "init.defaultBranch"], { cwd: root })
303
- .then(result => result.stdout.trim() || "main")
304
- .catch(() => "main")
305
- }
306
-
307
- async function readBacklogPlatform(root) {
308
- const raw = await readFile(path.join(root, ".opencode", "harness.json"))
309
- try {
310
- return JSON.parse(raw)?.platform?.backlog ?? "none"
311
- } catch {
312
- return "none"
313
- }
314
- }
315
-
316
- function reminder(missing) {
317
- const skills = [...missing].map(name => `\`${name}\``).join(", ")
318
- return `<system-reminder>Load these required skills before continuing: ${skills}. Guardrails first. A loaded skill can require further loads; follow those too. Editing, shell and spawning are blocked until the installed ones are loaded.</system-reminder>`
319
- }
320
-
321
- export default Plugin.define({
322
- id: "pc.system-reminders",
323
- async setup(ctx) {
324
- const root = ctx.location.directory || process.cwd()
325
- const sessions = new Map()
326
- let defaultBranch
327
- let backlogPlatform
328
-
329
- async function stateFor(sessionID, agent) {
330
- const state = sessions.get(sessionID)
331
- if (state?.agent === agent) return state
332
- const required = await requiredSkills(root, agent)
333
- const next = {
334
- agent,
335
- required,
336
- present: await installedSkills(root, required),
337
- // A session can switch agent (plan <-> build). What has been loaded is a
338
- // property of the session, not the agent, so it carries across.
339
- loaded: state?.loaded ?? new Set(),
340
- }
341
- sessions.set(sessionID, next)
342
- return next
343
- }
344
-
345
- function missingFor(state) {
346
- return [...state.required].filter(name => !state.loaded.has(name) && state.present.has(name))
347
- }
348
-
349
- // System instructions that name the trust boundary and, until the session's
350
- // required skills are loaded, that work is blocked. V2 applies this to every
351
- // request through the agent loop, which is at least as strong as the V1
352
- // message-transcript injection it replaces.
353
- await ctx.session.hook("context", async event => {
354
- try {
355
- event.system.push({ type: "text", text: TRUST_REMINDER })
356
- const state = await stateFor(event.sessionID, event.agent)
357
- const missing = missingFor(state)
358
- if (missing.length > 0) event.system.push({ type: "text", text: reminder(missing) })
359
- } catch (error) {
360
- console.error(`[harness] reminder hook failed open: ${error?.message}`)
361
- }
362
- })
363
-
364
- await ctx.permission.hook("evaluate", async event => {
365
- try {
366
- const state = await stateFor(event.sessionID, event.agent)
367
-
368
- // Skill activation is how the gate learns a skill was loaded. V2 routes
369
- // skill use through the `skill` action (resource = skill ID); an allow
370
- // decision still runs this hook, so it is the observation point.
371
- if (event.action === "skill") {
372
- for (const resource of event.resources) state.loaded.add(resource)
373
- return
374
- }
375
-
376
- if (GATED_ACTIONS.has(event.action)) {
377
- const missing = missingFor(state)
378
- if (missing.length > 0) {
379
- deny(
380
- event,
381
- `Required skills are not loaded yet: ${missing.map(name => `\`${name}\``).join(", ")}.`,
382
- `Call skill(${JSON.stringify(missing[0])}) first, guardrails first. Editing, shell and spawning stay blocked until then.`,
383
- )
384
- return
385
- }
386
- }
387
-
388
- if (event.action === "edit") {
389
- for (const resource of event.resources) {
390
- if (checkWritePath(event, resource, root)) return
391
- }
392
- if (event.effect !== "deny") await checkPlatform(event, root)
393
- }
394
-
395
- if (event.action === "webfetch") {
396
- for (const resource of event.resources) {
397
- backlogPlatform ??= await readBacklogPlatform(root)
398
- if (checkHost(event, resource)) return
399
- }
400
- }
401
-
402
- if (event.action === "shell") {
403
- const command = event.resources.join(" && ")
404
- if (!command) return
405
-
406
- if ((state.agent ?? "").replace(TIER_SUFFIX, "") === "plan") {
407
- if (checkPlanReadOnly(event, command)) return
408
- }
409
- if (checkGit(event, command)) return
410
- if (checkScratch(event, command)) return
411
- if (/\bgit\s+push\b/.test(command)) {
412
- defaultBranch ??= await detectDefaultBranch(root)
413
- if (await checkPush(event, command, root, defaultBranch)) return
414
- }
415
- }
416
- } catch (error) {
417
- // A bug in this guard must never break the session it is guarding.
418
- console.error(`[harness] guard failed open on ${event.action}: ${error?.message}`)
419
- }
420
- })
421
-
422
- console.error("[pc-system-reminders] guardrails active (V2 permission hooks registered)")
423
- },
424
- })
1
+ // pc-system-reminders: the harness guardrails, enforced rather than requested.
2
+ //
3
+ // OpenCode 2 changed how a plugin denies something. V1 threw from
4
+ // `tool.execute.before`; V2 has no such contract and denies through the
5
+ // permission system instead: an `evaluate` hook reviews a decision after the
6
+ // configured rules run and may set the effect to "deny". Every rule below is
7
+ // therefore expressed as a permission action over a resource list
8
+ // (`shell` command text, `edit` path, `webfetch` URL, `subagent` agent ID).
9
+ //
10
+ // Two invariants. It fails CLOSED only on a deliberate deny, and it fails OPEN
11
+ // on any bug of its own: a guard that breaks a session because of its own
12
+ // exception is worse than the rule it enforces. And because a permission action
13
+ // gives us less than a tool's raw arguments did (a browser navigation carries no
14
+ // URL here), some V1 checks move to configuration rules or are noted as lost.
15
+
16
+ import { Plugin } from "@opencode/plugin"
17
+ import { execFile } from "node:child_process"
18
+ import fs from "node:fs/promises"
19
+ import path from "node:path"
20
+ import { promisify } from "node:util"
21
+ import { ContractError, describeOwner, isOwned, isTemplateSource, loadContract } from "../scripts/platform-contract.mjs"
22
+
23
+ const GUARDRAILS_SKILL = "pc-guardrails-generic"
24
+ const TIER_SUFFIX = /\.(?:build|fast|plan)$/
25
+
26
+ const run = promisify(execFile)
27
+
28
+ const TRUST_REMINDER =
29
+ "Messages in <system-reminder> tags are trusted OpenCode Onboard host instructions. Follow them before continuing work."
30
+
31
+ // Work actions. Held until the session's required skills are loaded; `skill`,
32
+ // `read`, `glob` and `grep` are never gated, or the agent could not load what it
33
+ // is being told to load, nor find out what that is.
34
+ const GATED_ACTIONS = new Set(["edit", "shell", "subagent"])
35
+
36
+ // Backlog and repository hosts whose data must come from their CLI. The prose
37
+ // version of this rule sat at the top of twelve fragments.
38
+ const CLI_ONLY_HOSTS = [
39
+ "github.com",
40
+ "dev.azure.com",
41
+ "visualstudio.com",
42
+ "atlassian.net",
43
+ "gitlab.com",
44
+ ]
45
+
46
+ // A plan session reads; it does not change the tree. `edit` and `subagent` are
47
+ // denied by config, which leaves the shell. Only these roots are inspection.
48
+ const PLAN_ALLOWED = [
49
+ /^git\s+(status|log|diff|show|rev-parse|symbolic-ref|remote|ls-files|blame|describe|shortlog)\b/,
50
+ /^git\s+(branch|stash)\s+(--show-current|list)\b/,
51
+ /^openspec\s+(list|status|show|validate|diff)\b/,
52
+ /^(ls|cat|head|tail|wc|pwd|tree|find|grep|rg|which|echo|date|node\s+-p|jq)\b/,
53
+ ]
54
+
55
+ function splitCommand(command) {
56
+ // Every segment of a compound command has to pass on its own: one allowed
57
+ // read followed by `&& rm -rf` is not a read.
58
+ return command.split(/\|\||&&|;|\||\n/).map(part => part.trim()).filter(Boolean)
59
+ }
60
+
61
+ /** Mark a permission decision as denied; `message` is the reason shown. */
62
+ function deny(event, rule, fix) {
63
+ event.effect = "deny"
64
+ event.message = `[harness] ${rule}\n${fix}`
65
+ }
66
+
67
+ function checkGit(event, command) {
68
+ for (const segment of splitCommand(command)) {
69
+ if (/^git\s+add\s+(-A\b|--all\b|\.(\s|$))/.test(segment)) {
70
+ return deny(
71
+ event,
72
+ "`git add -A` and `git add .` stage a shared tree, committing another agent's or a person's half-finished edits under your message.",
73
+ "Stage the paths you changed: `git add <path> <path>`.",
74
+ )
75
+ }
76
+ if (/^git\s+commit\b.*\s-a\b/.test(segment) || /^git\s+commit\s+-[a-z]*a[a-z]*\b/.test(segment)) {
77
+ return deny(
78
+ event,
79
+ "`git commit -a` stages every tracked change, including work that is not yours.",
80
+ "Stage named paths first, then commit without `-a`.",
81
+ )
82
+ }
83
+ if (/^git\s+clean\b/.test(segment) && !/\s--\s+\S/.test(segment)) {
84
+ return deny(
85
+ event,
86
+ "`git clean` without `-- <paths>` deletes untracked files anywhere in the tree, including work nobody has committed yet.",
87
+ "Scope it: `git clean -f -- <path> <path>`.",
88
+ )
89
+ }
90
+ if (/^git\s+reset\s+--hard\b/.test(segment)) {
91
+ return deny(
92
+ event,
93
+ "`git reset --hard` discards uncommitted work in a tree you may be sharing.",
94
+ "Revert the paths you touched: `git checkout -- <path>`.",
95
+ )
96
+ }
97
+ if (/^git\s+stash\s+(drop|clear)\b/.test(segment)) {
98
+ return deny(
99
+ event,
100
+ "Dropping a stash destroys work that was set aside, possibly not yours.",
101
+ "Leave the stash and report its `git stash list` reference.",
102
+ )
103
+ }
104
+ if (/^git\s+(checkout|restore)\s+\.(\s|$)/.test(segment)) {
105
+ return deny(
106
+ event,
107
+ "`git checkout .` and `git restore .` discard every uncommitted change in the tree.",
108
+ "Name the paths to revert: `git checkout -- <path>`.",
109
+ )
110
+ }
111
+ if (/^git\s+push\b/.test(segment) && /--force\b/.test(segment) && !/--force-with-lease\b/.test(segment)) {
112
+ return deny(
113
+ event,
114
+ "`git push --force` can overwrite commits someone else pushed.",
115
+ "Use `--force-with-lease`, which refuses when the remote moved.",
116
+ )
117
+ }
118
+ }
119
+ }
120
+
121
+ async function checkPush(event, command, root, defaultBranch) {
122
+ if (!defaultBranch) return
123
+ for (const segment of splitCommand(command)) {
124
+ if (!/^git\s+push\b/.test(segment)) continue
125
+ // Split on delimiters rather than interpolating the branch name into a
126
+ // pattern: a branch name is not a pattern, and a dynamic RegExp was both a
127
+ // Semgrep finding and a hole in the guard.
128
+ const named = segment.split(/[\s:]+/).includes(defaultBranch)
129
+ if (named) {
130
+ return deny(
131
+ event,
132
+ `This pushes \`${defaultBranch}\`, the default branch. The harness ships work on a branch and lets a human merge it.`,
133
+ "Push your work branch instead, or open a pull request.",
134
+ )
135
+ }
136
+ if (!/\s(origin|upstream)\b/.test(segment) || /^git\s+push\s*$/.test(segment)) {
137
+ const current = await run("git", ["branch", "--show-current"], { cwd: root })
138
+ .then(result => result.stdout.trim())
139
+ .catch(() => "")
140
+ if (current && current === defaultBranch) {
141
+ return deny(
142
+ event,
143
+ `You are on \`${defaultBranch}\`, the default branch, and this pushes it.`,
144
+ "Move the work to a branch first: `git switch -c feature/<slug>`.",
145
+ )
146
+ }
147
+ }
148
+ }
149
+ }
150
+
151
+ // `/tmp/gh-aw/` is the agent workflow runtime's own working directory, not agent
152
+ // scratch. GitHub Agentic Workflows stages the issue context there and the
153
+ // worker prompts name those paths, so writes inside it are expected.
154
+ function checkScratch(event, command) {
155
+ const writesSomewhere = /(>>?|\btee\b|\bcp\b|\bmv\b|\bmkdir\b|\btouch\b|\bdd\b)/.test(command)
156
+ const outsideRepo = /(\/tmp\/(?!gh-aw\/)|\$TMPDIR|\$TEMP\b|%TEMP%|\bmktemp\b)/.test(command)
157
+ if (writesSomewhere && outsideRepo) {
158
+ return deny(
159
+ event,
160
+ "Scratch files belong inside the repository, where the next step and the next agent can still find them.",
161
+ "Write under `$REPO_ROOT/.opencode/.tmp/`, or the workflow runtime's own `/tmp/gh-aw/`.",
162
+ )
163
+ }
164
+ }
165
+
166
+ function checkWritePath(event, target, root) {
167
+ if (typeof target !== "string" || !target) return
168
+ if (!path.isAbsolute(target)) return
169
+ // Windows hands back mixed drive-letter case, so compare case-insensitively
170
+ // there and exactly everywhere else.
171
+ const normalize = value => (process.platform === "win32" ? path.resolve(value).toLowerCase() : path.resolve(value))
172
+ const resolved = normalize(target)
173
+ const base = normalize(root)
174
+ if (resolved === base || resolved.startsWith(base + path.sep)) return
175
+ return deny(
176
+ event,
177
+ `\`${target}\` is outside the repository, so nothing else in the run can see it and nobody will clean it up.`,
178
+ "Write under `$REPO_ROOT/.opencode/.tmp/` instead.",
179
+ )
180
+ }
181
+
182
+ const repoRelative = (target, root) =>
183
+ (path.isAbsolute(target) ? path.relative(root, target) : target).replace(/\\/g, "/").replace(/^\.\//, "")
184
+
185
+ function isOwnedTarget(target, root, contract) {
186
+ if (typeof target !== "string" || !target) return false
187
+ const relative = repoRelative(target, root)
188
+ return !relative.startsWith("..") && isOwned(contract, relative)
189
+ }
190
+
191
+ // A template sync in progress has changed its marker file, and resolving its conflicts edits owned files.
192
+ async function syncInProgress(root, marker) {
193
+ if (!marker) return false
194
+ try {
195
+ const { stdout } = await run("git", ["status", "--porcelain", "--", marker], { cwd: root })
196
+ return stdout.trim() !== ""
197
+ } catch {
198
+ return false
199
+ }
200
+ }
201
+
202
+ async function checkPlatform(event, root) {
203
+ let contract
204
+ try {
205
+ contract = loadContract(root)
206
+ } catch (error) {
207
+ if (!(error instanceof ContractError)) throw error
208
+ // An unreadable contract must not pass every edit: only the files that fix it stay editable.
209
+ if (event.resources.every(target => repoRelative(String(target), root).startsWith(".platform/"))) return
210
+ return deny(event, error.message, "Fix that file before editing anything else: until it reads, nothing can tell which files the template owns.")
211
+ }
212
+ const owned = contract ? event.resources.filter(target => isOwnedTarget(target, root, contract)) : []
213
+ if (!owned.length || isTemplateSource(root, contract) || await syncInProgress(root, contract.sync.marker)) return
214
+ const { owner, fix } = describeOwner(contract)
215
+ return deny(event, `\`${owned[0]}\` is ${owner}.`, fix)
216
+ }
217
+
218
+ function checkHost(event, url) {
219
+ if (typeof url !== "string" || !url) return
220
+ let host = ""
221
+ try {
222
+ host = new URL(url).hostname.toLowerCase()
223
+ } catch {
224
+ return
225
+ }
226
+ const cliOnly = CLI_ONLY_HOSTS.find(candidate => host === candidate || host.endsWith(`.${candidate}`))
227
+ if (cliOnly) {
228
+ return deny(
229
+ event,
230
+ `${cliOnly} data must come from its CLI (\`gh\`, \`az\`, \`glab\`, \`acli\`), which is authenticated and returns structured output. A page fetch returns whatever HTML the browser would see.`,
231
+ "Use the platform CLI. If it is unavailable, report that as a blocker.",
232
+ )
233
+ }
234
+ }
235
+
236
+ function checkPlanReadOnly(event, command) {
237
+ for (const segment of splitCommand(command)) {
238
+ // `echo` and `cat` are inspection right up to the point a redirect turns
239
+ // them into a write, so the redirect is checked before the allowlist.
240
+ if (/(^|\s)>>?\s*\S/.test(segment) || /\btee\b/.test(segment)) {
241
+ return deny(
242
+ event,
243
+ "The plan agent reads; a redirect writes to the tree.",
244
+ "Switch to the build agent to make changes.",
245
+ )
246
+ }
247
+ if (PLAN_ALLOWED.some(pattern => pattern.test(segment))) continue
248
+ return deny(
249
+ event,
250
+ `The plan agent reads; it does not change the tree, and \`${segment.split(/\s+/)[0]}\` is not one of its inspection commands.`,
251
+ "Switch to the build agent to make changes.",
252
+ )
253
+ }
254
+ }
255
+
256
+ function skillNames(content) {
257
+ const abilities = content.match(/^## Abilities\s*\n([\s\S]*?)(?=^## |\s*$)/m)?.[1] ?? ""
258
+ return [...abilities.matchAll(/@([a-z0-9][a-z0-9-]*)/gi)].map(match => match[1])
259
+ }
260
+
261
+ function transitiveSkillNames(content) {
262
+ return [...content.matchAll(/skill\(["`]([a-z0-9][a-z0-9-]*)["`]\)/gi)].map(match => match[1])
263
+ }
264
+
265
+ async function readFile(filePath) {
266
+ try {
267
+ return await fs.readFile(filePath, "utf-8")
268
+ } catch {
269
+ return ""
270
+ }
271
+ }
272
+
273
+ async function requiredSkills(directory, agent) {
274
+ const baseAgent = (agent ?? "").replace(TIER_SUFFIX, "")
275
+ const agentPath = path.join(directory, ".opencode", "agents", `${baseAgent}.md`)
276
+ const guardrailsPath = path.join(directory, ".agents", "skills", GUARDRAILS_SKILL, "SKILL.md")
277
+ const [agentContent, guardrailsContent] = await Promise.all([readFile(agentPath), readFile(guardrailsPath)])
278
+ return new Set([GUARDRAILS_SKILL, ...skillNames(agentContent), ...transitiveSkillNames(guardrailsContent)])
279
+ }
280
+
281
+ // Only skills that exist on disk can gate work. An `@skill` reference to
282
+ // something uninstalled would otherwise be unloadable and deadlock the worker,
283
+ // which is a worse failure than the dangling reference itself.
284
+ async function installedSkills(directory, names) {
285
+ const present = new Set()
286
+ await Promise.all([...names].map(async name => {
287
+ try {
288
+ await fs.access(path.join(directory, ".agents", "skills", name, "SKILL.md"))
289
+ present.add(name)
290
+ } catch {
291
+ // not installed; the reminder still names it, the gate ignores it
292
+ }
293
+ }))
294
+ return present
295
+ }
296
+
297
+ async function detectDefaultBranch(root) {
298
+ const fromRemote = await run("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd: root })
299
+ .then(result => result.stdout.trim().replace(/^origin\//, ""))
300
+ .catch(() => "")
301
+ if (fromRemote) return fromRemote
302
+ return run("git", ["config", "--get", "init.defaultBranch"], { cwd: root })
303
+ .then(result => result.stdout.trim() || "main")
304
+ .catch(() => "main")
305
+ }
306
+
307
+ async function readBacklogPlatform(root) {
308
+ const raw = await readFile(path.join(root, ".opencode", "harness.json"))
309
+ try {
310
+ return JSON.parse(raw)?.platform?.backlog ?? "none"
311
+ } catch {
312
+ return "none"
313
+ }
314
+ }
315
+
316
+ function reminder(missing) {
317
+ const skills = [...missing].map(name => `\`${name}\``).join(", ")
318
+ return `<system-reminder>Load these required skills before continuing: ${skills}. Guardrails first. A loaded skill can require further loads; follow those too. Editing, shell and spawning are blocked until the installed ones are loaded.</system-reminder>`
319
+ }
320
+
321
+ export default Plugin.define({
322
+ id: "pc.system-reminders",
323
+ async setup(ctx) {
324
+ const root = ctx.location.directory || process.cwd()
325
+ const sessions = new Map()
326
+ let defaultBranch
327
+ let backlogPlatform
328
+
329
+ async function stateFor(sessionID, agent) {
330
+ const state = sessions.get(sessionID)
331
+ if (state?.agent === agent) return state
332
+ const required = await requiredSkills(root, agent)
333
+ const next = {
334
+ agent,
335
+ required,
336
+ present: await installedSkills(root, required),
337
+ // A session can switch agent (plan <-> build). What has been loaded is a
338
+ // property of the session, not the agent, so it carries across.
339
+ loaded: state?.loaded ?? new Set(),
340
+ }
341
+ sessions.set(sessionID, next)
342
+ return next
343
+ }
344
+
345
+ function missingFor(state) {
346
+ return [...state.required].filter(name => !state.loaded.has(name) && state.present.has(name))
347
+ }
348
+
349
+ // System instructions that name the trust boundary and, until the session's
350
+ // required skills are loaded, that work is blocked. V2 applies this to every
351
+ // request through the agent loop, which is at least as strong as the V1
352
+ // message-transcript injection it replaces.
353
+ await ctx.session.hook("context", async event => {
354
+ try {
355
+ event.system.push({ type: "text", text: TRUST_REMINDER })
356
+ const state = await stateFor(event.sessionID, event.agent)
357
+ const missing = missingFor(state)
358
+ if (missing.length > 0) event.system.push({ type: "text", text: reminder(missing) })
359
+ } catch (error) {
360
+ console.error(`[harness] reminder hook failed open: ${error?.message}`)
361
+ }
362
+ })
363
+
364
+ await ctx.permission.hook("evaluate", async event => {
365
+ try {
366
+ const state = await stateFor(event.sessionID, event.agent)
367
+
368
+ // Skill activation is how the gate learns a skill was loaded. V2 routes
369
+ // skill use through the `skill` action (resource = skill ID); an allow
370
+ // decision still runs this hook, so it is the observation point.
371
+ if (event.action === "skill") {
372
+ for (const resource of event.resources) state.loaded.add(resource)
373
+ return
374
+ }
375
+
376
+ if (GATED_ACTIONS.has(event.action)) {
377
+ const missing = missingFor(state)
378
+ if (missing.length > 0) {
379
+ deny(
380
+ event,
381
+ `Required skills are not loaded yet: ${missing.map(name => `\`${name}\``).join(", ")}.`,
382
+ `Call skill(${JSON.stringify(missing[0])}) first, guardrails first. Editing, shell and spawning stay blocked until then.`,
383
+ )
384
+ return
385
+ }
386
+ }
387
+
388
+ if (event.action === "edit") {
389
+ for (const resource of event.resources) {
390
+ if (checkWritePath(event, resource, root)) return
391
+ }
392
+ if (event.effect !== "deny") await checkPlatform(event, root)
393
+ }
394
+
395
+ if (event.action === "webfetch") {
396
+ for (const resource of event.resources) {
397
+ backlogPlatform ??= await readBacklogPlatform(root)
398
+ if (checkHost(event, resource)) return
399
+ }
400
+ }
401
+
402
+ if (event.action === "shell") {
403
+ const command = event.resources.join(" && ")
404
+ if (!command) return
405
+
406
+ if ((state.agent ?? "").replace(TIER_SUFFIX, "") === "plan") {
407
+ if (checkPlanReadOnly(event, command)) return
408
+ }
409
+ if (checkGit(event, command)) return
410
+ if (checkScratch(event, command)) return
411
+ if (/\bgit\s+push\b/.test(command)) {
412
+ defaultBranch ??= await detectDefaultBranch(root)
413
+ if (await checkPush(event, command, root, defaultBranch)) return
414
+ }
415
+ }
416
+ } catch (error) {
417
+ // A bug in this guard must never break the session it is guarding.
418
+ console.error(`[harness] guard failed open on ${event.action}: ${error?.message}`)
419
+ }
420
+ })
421
+
422
+ console.error("[pc-system-reminders] guardrails active (V2 permission hooks registered)")
423
+ },
424
+ })