@plainconceptsplatform/agent-harness 2.2.0 → 2.3.0

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.
@@ -1,85 +1,88 @@
1
- import fse from 'fs-extra'
2
- import path from 'path'
3
- import { success } from '../../utils/exec.js'
4
-
5
- const FULLSTACK_FILE = 'fullstack-engineer.md'
6
- const FULLSTACK_DESCRIPTION = 'Default engineer that accumulates skills from all created persona engineers. Use as fallback when no specialist matches: but prefer spawning a specific engineer for deterministic results.'
7
- const FULLSTACK_IDENTITY = 'You are the default engineer, and your body is what the build and plan agents run. You are more complete but less accurate than specialized engineers, so prefer spawning a specialist when one matches the task domain.'
8
-
9
- // The reminder plugin owns ability bootstrap. Remove the old prompt-level
10
- // directive when regenerating so it cannot duplicate the plugin reminder.
11
- function stripStartupDirective(text) {
12
- if (!text) return text
13
- return text
14
- .split(/\r?\n\r?\n+/)
15
- .filter(paragraph => !paragraph.trim().startsWith('**Startup'))
16
- .join('\n\n')
17
- .trim()
18
- }
19
-
20
- export async function removeLegacyStartupDirectives({ cwd = process.cwd() } = {}) {
21
- const agentsDir = path.join(cwd, '.opencode', 'agents')
22
- if (!await fse.pathExists(agentsDir)) return 0
23
-
24
- let updated = 0
25
- for (const fileName of await fse.readdir(agentsDir)) {
26
- if (!fileName.endsWith('.md')) continue
27
- const filePath = path.join(agentsDir, fileName)
28
- const content = await fse.readFile(filePath, 'utf-8')
29
- const cleaned = stripStartupDirective(content)
30
- if (cleaned === content.trim()) continue
31
- await fse.writeFile(filePath, `${cleaned}\n`, 'utf-8')
32
- updated++
33
- }
34
- return updated
35
- }
36
-
37
- export async function generateFullstackEngineer({ cwd = process.cwd(), updateMode = false } = {}) {
38
- const agentsDir = path.join(cwd, '.opencode', 'agents')
39
- await fse.ensureDir(agentsDir)
40
- const filePath = path.join(agentsDir, FULLSTACK_FILE)
41
-
42
- if (updateMode && await fse.pathExists(filePath)) {
43
- return { generated: false, preserved: true }
44
- }
45
-
46
- let existingAbilities = null
47
- let existingIdentity = null
48
- if (await fse.pathExists(filePath)) {
49
- const content = await fse.readFile(filePath, 'utf-8')
50
- const abilitiesMatch = content.match(/## Abilities\n([\s\S]*?)$/)
51
- if (abilitiesMatch) existingAbilities = abilitiesMatch[1].trim()
52
- const identityMatch = content.match(/^---\n[\s\S]*?\n---\n\n(.*?)(?:\n\n## Abilities)/s)
53
- if (identityMatch) existingIdentity = stripStartupDirective(identityMatch[1])
54
- }
55
-
56
- const frontmatter = [
57
- '---',
58
- `description: ${FULLSTACK_DESCRIPTION}`,
59
- // subagent, not primary: build.md and plan.md are the primaries, and
60
- // pc-subagent-tiers generates both from this file on every startup.
61
- 'mode: subagent',
62
- 'color: warning',
63
- 'permission:',
64
- ' edit: allow',
65
- ' bash: allow',
66
- ' read: allow',
67
- ' glob: allow',
68
- ' grep: allow',
69
- ' question: allow',
70
- ' todowrite: allow',
71
- '---',
72
- ]
73
-
74
- const identity = existingIdentity ?? FULLSTACK_IDENTITY
75
-
76
- const abilities = existingAbilities ?? [
77
- '- Guardrails: @pc-guardrails-generic, @pc-guardrails-project',
78
- ].join('\n')
79
-
80
- const content = `${frontmatter.join('\n')}\n\n${identity}\n\n## Abilities\n${abilities}\n`
81
-
82
- await fse.writeFile(filePath, content, 'utf-8')
83
- success(`Generated ${FULLSTACK_FILE}`)
84
- return { generated: true }
85
- }
1
+ import fse from 'fs-extra'
2
+ import path from 'path'
3
+ import { success } from '../../utils/exec.js'
4
+ import { agentColor } from '../../utils/agent-color.js'
5
+
6
+ const FULLSTACK_FILE = 'fullstack-engineer.md'
7
+ const FULLSTACK_DESCRIPTION = 'Default engineer that accumulates skills from all created persona engineers. Use as fallback when no specialist matches: but prefer spawning a specific engineer for deterministic results.'
8
+ const FULLSTACK_IDENTITY = 'You are the default engineer, and your body is what the build and plan agents run. You are more complete but less accurate than specialized engineers, so prefer spawning a specialist when one matches the task domain.'
9
+
10
+ // The reminder plugin owns ability bootstrap. Remove the old prompt-level
11
+ // directive when regenerating so it cannot duplicate the plugin reminder.
12
+ function stripStartupDirective(text) {
13
+ if (!text) return text
14
+ return text
15
+ .split(/\r?\n\r?\n+/)
16
+ .filter(paragraph => !paragraph.trim().startsWith('**Startup'))
17
+ .join('\n\n')
18
+ .trim()
19
+ }
20
+
21
+ export async function removeLegacyStartupDirectives({ cwd = process.cwd() } = {}) {
22
+ const agentsDir = path.join(cwd, '.opencode', 'agents')
23
+ if (!await fse.pathExists(agentsDir)) return 0
24
+
25
+ let updated = 0
26
+ for (const fileName of await fse.readdir(agentsDir)) {
27
+ if (!fileName.endsWith('.md')) continue
28
+ const filePath = path.join(agentsDir, fileName)
29
+ const content = await fse.readFile(filePath, 'utf-8')
30
+ const cleaned = stripStartupDirective(content)
31
+ if (cleaned === content.trim()) continue
32
+ await fse.writeFile(filePath, `${cleaned}\n`, 'utf-8')
33
+ updated++
34
+ }
35
+ return updated
36
+ }
37
+
38
+ export async function generateFullstackEngineer({ cwd = process.cwd(), updateMode = false } = {}) {
39
+ const agentsDir = path.join(cwd, '.opencode', 'agents')
40
+ await fse.ensureDir(agentsDir)
41
+ const filePath = path.join(agentsDir, FULLSTACK_FILE)
42
+
43
+ if (updateMode && await fse.pathExists(filePath)) {
44
+ return { generated: false, preserved: true }
45
+ }
46
+
47
+ let existingAbilities = null
48
+ let existingIdentity = null
49
+ if (await fse.pathExists(filePath)) {
50
+ const content = await fse.readFile(filePath, 'utf-8')
51
+ const abilitiesMatch = content.match(/## Abilities\n([\s\S]*?)$/)
52
+ if (abilitiesMatch) existingAbilities = abilitiesMatch[1].trim()
53
+ const identityMatch = content.match(/^---\n[\s\S]*?\n---\n\n(.*?)(?:\n\n## Abilities)/s)
54
+ if (identityMatch) existingIdentity = stripStartupDirective(identityMatch[1])
55
+ }
56
+
57
+ const frontmatter = [
58
+ '---',
59
+ `description: ${FULLSTACK_DESCRIPTION}`,
60
+ // subagent, not primary: build.md and plan.md are the primaries, and
61
+ // pc-subagent-tiers generates both from this file on every startup.
62
+ 'mode: subagent',
63
+ // warning is reserved for plan, so fullstack takes its name-derived hex
64
+ // like every other subagent.
65
+ `color: ${agentColor('fullstack-engineer')}`,
66
+ 'permission:',
67
+ ' edit: allow',
68
+ ' bash: allow',
69
+ ' read: allow',
70
+ ' glob: allow',
71
+ ' grep: allow',
72
+ ' question: allow',
73
+ ' todowrite: allow',
74
+ '---',
75
+ ]
76
+
77
+ const identity = existingIdentity ?? FULLSTACK_IDENTITY
78
+
79
+ const abilities = existingAbilities ?? [
80
+ '- Guardrails: @pc-guardrails-generic, @pc-guardrails-project',
81
+ ].join('\n')
82
+
83
+ const content = `${frontmatter.join('\n')}\n\n${identity}\n\n## Abilities\n${abilities}\n`
84
+
85
+ await fse.writeFile(filePath, content, 'utf-8')
86
+ success(`Generated ${FULLSTACK_FILE}`)
87
+ return { generated: true }
88
+ }
@@ -1,147 +1,161 @@
1
- import fse from 'fs-extra'
2
- import { applyEdits, modify, parse } from 'jsonc-parser'
3
- import path from 'node:path'
4
- import { success } from '../../utils/exec.js'
5
-
6
- // The reminder plugin loads every agent ability before work, including
7
- // user-installed skills outside the pc-* and openspec-* namespaces.
8
- const SHARED_PERMISSIONS = [
9
- ['question', 'allow'],
10
- ['todowrite', 'allow'],
11
- ]
12
-
13
- function applyModify(text, jsonPath, value) {
14
- const edits = modify(text, jsonPath, value, {
15
- formattingOptions: { insertSpaces: true, tabSize: 2 },
16
- })
17
- return applyEdits(text, edits)
18
- }
19
-
20
- export async function patchOpencodeJson(cwd = process.cwd()) {
21
- const opencodePath = path.join(cwd, 'opencode.jsonc')
22
-
23
- let text
24
- if (await fse.pathExists(opencodePath)) {
25
- text = await fse.readFile(opencodePath, 'utf-8')
26
- } else {
27
- text = JSON.stringify({ $schema: 'https://opencode.ai/config.json' }, null, 2)
28
- }
29
-
30
- const errors = []
31
- const parsed = parse(text, errors)
32
- if (errors.length > 0 || typeof parsed !== 'object') {
33
- return { patched: false, reason: 'parse error' }
34
- }
35
-
36
- // build and plan are overridden, not disabled. Earlier versions disabled them
37
- // and pointed default_agent at fullstack-engineer; now they are the only two
38
- // primaries and pc-subagent-tiers regenerates their agent files from
39
- // fullstack-engineer.md. A repo patched by an earlier version still carries
40
- // `disable: true`, which would hide both agents, so that flag is cleared here.
41
- const hasStaleDisable = (
42
- parsed?.agent?.build?.disable !== undefined ||
43
- parsed?.agent?.plan?.disable !== undefined
44
- )
45
- const needsAgentOverride = hasStaleDisable || !(
46
- parsed?.agent?.build?.mode === 'primary' &&
47
- parsed?.agent?.plan?.mode === 'primary' &&
48
- parsed?.agent?.plan?.permission?.edit === 'deny'
49
- )
50
- const needsSkillPermission = parsed?.permission?.skill !== 'allow'
51
- const missingSharedPermissions = SHARED_PERMISSIONS.filter(
52
- ([key, value]) => parsed?.permission?.[key] !== value,
53
- )
54
- // opencode only scans .opencode/skills/ by default, but we install to .agents/skills/
55
- const needsSkillsPaths = !(
56
- Array.isArray(parsed?.skills?.paths) &&
57
- parsed.skills.paths.includes('.agents/skills')
58
- )
59
- const needsCompaction = !(
60
- parsed?.compaction?.auto === true &&
61
- parsed?.compaction?.prune === true
62
- )
63
-
64
- if (!needsAgentOverride && !needsSkillPermission && missingSharedPermissions.length === 0 && !needsSkillsPaths && !needsCompaction) {
65
- return { patched: false }
66
- }
67
-
68
- // Apply edits sequentially so offsets stay correct
69
- if (needsAgentOverride) {
70
- // undefined removes the key, clearing the disable left by earlier versions.
71
- if (parsed?.agent?.build?.disable !== undefined) {
72
- text = applyModify(text, ['agent', 'build', 'disable'], undefined)
73
- }
74
- if (parsed?.agent?.plan?.disable !== undefined) {
75
- text = applyModify(text, ['agent', 'plan', 'disable'], undefined)
76
- }
77
- text = applyModify(text, ['agent', 'build', 'mode'], 'primary')
78
- text = applyModify(text, ['agent', 'plan', 'mode'], 'primary')
79
- text = applyModify(text, ['agent', 'plan', 'permission', 'edit'], 'deny')
80
- }
81
- if (needsSkillPermission) text = applyModify(text, ['permission', 'skill'], 'allow')
82
- for (const [key, value] of missingSharedPermissions) {
83
- text = applyModify(text, ['permission', key], value)
84
- }
85
-
86
- // Ensure .agents/skills is in skills.paths so opencode discovers installed skills
87
- if (needsSkillsPaths) {
88
- const existingPaths = Array.isArray(parsed?.skills?.paths) ? parsed.skills.paths : []
89
- if (!existingPaths.includes('.agents/skills')) {
90
- const updatedPaths = [...existingPaths, '.agents/skills']
91
- text = applyModify(text, ['skills', 'paths'], updatedPaths)
92
- }
93
- }
94
-
95
- // Enable context pruning to reduce token costs in long agent sessions
96
- if (needsCompaction) {
97
- text = applyModify(text, ['compaction', 'auto'], true)
98
- text = applyModify(text, ['compaction', 'prune'], true)
99
- text = applyModify(text, ['compaction', 'reserved'], 10000)
100
- }
101
-
102
- await fse.writeFile(opencodePath, text, 'utf-8')
103
- if (needsAgentOverride) {
104
- success('Set build/plan as the primary agents in opencode.jsonc (plan is read-only)')
105
- }
106
- if (needsSkillPermission) {
107
- success('Allowed skill loading in opencode.jsonc')
108
- }
109
- if (missingSharedPermissions.length > 0) {
110
- success('Allowed question/todowrite tools in opencode.jsonc')
111
- }
112
- if (needsSkillsPaths) {
113
- success('Added .agents/skills to skills.paths in opencode.jsonc')
114
- }
115
- if (needsCompaction) {
116
- success('Enabled context pruning (compaction.prune) in opencode.jsonc')
117
- }
118
-
119
- return { patched: true }
120
- }
121
-
122
- const OPENCODE_PACKAGE_DEPENDENCIES = {
123
- '@opencode-ai/plugin': '1.18.19',
124
- '@opentui/core': '0.5.6',
125
- '@opentui/solid': '0.5.6',
126
- 'solid-js': '1.9.12',
127
- }
128
-
129
- export async function patchOpencodePackage(cwd = process.cwd()) {
130
- const packagePath = path.join(cwd, '.opencode', 'package.json')
131
- if (!await fse.pathExists(packagePath)) return { patched: false }
132
-
133
- const packageJson = await fse.readJson(packagePath).catch(() => null)
134
- if (!packageJson?.dependencies) return { patched: false }
135
-
136
- let patched = false
137
- for (const [name, version] of Object.entries(OPENCODE_PACKAGE_DEPENDENCIES)) {
138
- if (!(name in packageJson.dependencies) || packageJson.dependencies[name] === version) continue
139
- packageJson.dependencies[name] = version
140
- patched = true
141
- }
142
- if (!patched) return { patched: false }
143
-
144
- await fse.writeJson(packagePath, packageJson, { spaces: 2 })
145
- success('Updated OpenCode plugin dependencies in .opencode/package.json')
146
- return { patched: true }
147
- }
1
+ import fse from 'fs-extra'
2
+ import { applyEdits, modify, parse } from 'jsonc-parser'
3
+ import path from 'node:path'
4
+ import { success } from '../../utils/exec.js'
5
+
6
+ // The reminder plugin loads every agent ability before work, including
7
+ // user-installed skills outside the pc-* and openspec-* namespaces.
8
+ const SHARED_PERMISSIONS = [
9
+ ['question', 'allow'],
10
+ ['todowrite', 'allow'],
11
+ ]
12
+
13
+ function applyModify(text, jsonPath, value) {
14
+ const edits = modify(text, jsonPath, value, {
15
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
16
+ })
17
+ return applyEdits(text, edits)
18
+ }
19
+
20
+ export async function patchOpencodeJson(cwd = process.cwd()) {
21
+ const opencodePath = path.join(cwd, 'opencode.jsonc')
22
+
23
+ let text
24
+ if (await fse.pathExists(opencodePath)) {
25
+ text = await fse.readFile(opencodePath, 'utf-8')
26
+ } else {
27
+ text = JSON.stringify({ $schema: 'https://opencode.ai/config.json' }, null, 2)
28
+ }
29
+
30
+ const errors = []
31
+ const parsed = parse(text, errors)
32
+ if (errors.length > 0 || typeof parsed !== 'object') {
33
+ return { patched: false, reason: 'parse error' }
34
+ }
35
+
36
+ // build and plan are overridden, not disabled. Earlier versions disabled them
37
+ // and pointed default_agent at fullstack-engineer; now they are the only two
38
+ // primaries and pc-subagent-tiers regenerates their agent files from
39
+ // fullstack-engineer.md. A repo patched by an earlier version still carries
40
+ // `disable: true`, which would hide both agents, so that flag is cleared here.
41
+ const hasStaleDisable = (
42
+ parsed?.agent?.build?.disable !== undefined ||
43
+ parsed?.agent?.plan?.disable !== undefined
44
+ )
45
+ const needsAgentOverride = hasStaleDisable || !(
46
+ parsed?.agent?.build?.mode === 'primary' &&
47
+ parsed?.agent?.plan?.mode === 'primary' &&
48
+ parsed?.agent?.plan?.permission?.edit === 'deny'
49
+ )
50
+
51
+ // default_agent pointing at fullstack-engineer is now invalid: it became a
52
+ // subagent when build and plan took over as the only primaries, so opencode
53
+ // has no valid default to open a session with. A deliberate choice of build
54
+ // or plan is left alone; anything else is repointed at plan, so a session
55
+ // starts read-only.
56
+ const currentDefault = parsed?.default_agent
57
+ const needsDefaultAgent = currentDefault !== 'plan' && currentDefault !== 'build'
58
+ const needsSkillPermission = parsed?.permission?.skill !== 'allow'
59
+ const missingSharedPermissions = SHARED_PERMISSIONS.filter(
60
+ ([key, value]) => parsed?.permission?.[key] !== value,
61
+ )
62
+ // opencode only scans .opencode/skills/ by default, but we install to .agents/skills/
63
+ const needsSkillsPaths = !(
64
+ Array.isArray(parsed?.skills?.paths) &&
65
+ parsed.skills.paths.includes('.agents/skills')
66
+ )
67
+ const needsCompaction = !(
68
+ parsed?.compaction?.auto === true &&
69
+ parsed?.compaction?.prune === true
70
+ )
71
+
72
+ if (!needsAgentOverride && !needsDefaultAgent && !needsSkillPermission && missingSharedPermissions.length === 0 && !needsSkillsPaths && !needsCompaction) {
73
+ return { patched: false }
74
+ }
75
+
76
+ // Apply edits sequentially so offsets stay correct
77
+ if (needsAgentOverride) {
78
+ // undefined removes the key, clearing the disable left by earlier versions.
79
+ if (parsed?.agent?.build?.disable !== undefined) {
80
+ text = applyModify(text, ['agent', 'build', 'disable'], undefined)
81
+ }
82
+ if (parsed?.agent?.plan?.disable !== undefined) {
83
+ text = applyModify(text, ['agent', 'plan', 'disable'], undefined)
84
+ }
85
+ text = applyModify(text, ['agent', 'build', 'mode'], 'primary')
86
+ text = applyModify(text, ['agent', 'plan', 'mode'], 'primary')
87
+ text = applyModify(text, ['agent', 'plan', 'permission', 'edit'], 'deny')
88
+ }
89
+ if (needsDefaultAgent) {
90
+ text = applyModify(text, ['default_agent'], 'plan')
91
+ }
92
+ if (needsSkillPermission) text = applyModify(text, ['permission', 'skill'], 'allow')
93
+ for (const [key, value] of missingSharedPermissions) {
94
+ text = applyModify(text, ['permission', key], value)
95
+ }
96
+
97
+ // Ensure .agents/skills is in skills.paths so opencode discovers installed skills
98
+ if (needsSkillsPaths) {
99
+ const existingPaths = Array.isArray(parsed?.skills?.paths) ? parsed.skills.paths : []
100
+ if (!existingPaths.includes('.agents/skills')) {
101
+ const updatedPaths = [...existingPaths, '.agents/skills']
102
+ text = applyModify(text, ['skills', 'paths'], updatedPaths)
103
+ }
104
+ }
105
+
106
+ // Enable context pruning to reduce token costs in long agent sessions
107
+ if (needsCompaction) {
108
+ text = applyModify(text, ['compaction', 'auto'], true)
109
+ text = applyModify(text, ['compaction', 'prune'], true)
110
+ text = applyModify(text, ['compaction', 'reserved'], 10000)
111
+ }
112
+
113
+ await fse.writeFile(opencodePath, text, 'utf-8')
114
+ if (needsAgentOverride) {
115
+ success('Set build/plan as the primary agents in opencode.jsonc (plan is read-only)')
116
+ }
117
+ if (needsDefaultAgent) {
118
+ success(`default_agent -> plan${currentDefault ? ` (was ${currentDefault})` : ''}`)
119
+ }
120
+ if (needsSkillPermission) {
121
+ success('Allowed skill loading in opencode.jsonc')
122
+ }
123
+ if (missingSharedPermissions.length > 0) {
124
+ success('Allowed question/todowrite tools in opencode.jsonc')
125
+ }
126
+ if (needsSkillsPaths) {
127
+ success('Added .agents/skills to skills.paths in opencode.jsonc')
128
+ }
129
+ if (needsCompaction) {
130
+ success('Enabled context pruning (compaction.prune) in opencode.jsonc')
131
+ }
132
+
133
+ return { patched: true }
134
+ }
135
+
136
+ const OPENCODE_PACKAGE_DEPENDENCIES = {
137
+ '@opencode-ai/plugin': '1.18.19',
138
+ '@opentui/core': '0.5.6',
139
+ '@opentui/solid': '0.5.6',
140
+ 'solid-js': '1.9.12',
141
+ }
142
+
143
+ export async function patchOpencodePackage(cwd = process.cwd()) {
144
+ const packagePath = path.join(cwd, '.opencode', 'package.json')
145
+ if (!await fse.pathExists(packagePath)) return { patched: false }
146
+
147
+ const packageJson = await fse.readJson(packagePath).catch(() => null)
148
+ if (!packageJson?.dependencies) return { patched: false }
149
+
150
+ let patched = false
151
+ for (const [name, version] of Object.entries(OPENCODE_PACKAGE_DEPENDENCIES)) {
152
+ if (!(name in packageJson.dependencies) || packageJson.dependencies[name] === version) continue
153
+ packageJson.dependencies[name] = version
154
+ patched = true
155
+ }
156
+ if (!patched) return { patched: false }
157
+
158
+ await fse.writeJson(packagePath, packageJson, { spaces: 2 })
159
+ success('Updated OpenCode plugin dependencies in .opencode/package.json')
160
+ return { patched: true }
161
+ }
@@ -59,6 +59,10 @@ export async function writeHarnessConfig(data) {
59
59
  ),
60
60
  codegraph: !!(optionalTools.codegraph?.optedIn ?? optionalTools.codegraph),
61
61
  memory: !!(optionalTools.memory?.optedIn ?? optionalTools.memory),
62
+ // humanizer was missing here, so a selection never survived a metadata
63
+ // write and patchGuardrails always saw it as off, leaving its marker
64
+ // section empty however the project was actually set up.
65
+ humanizer: !!(optionalTools.humanizer?.optedIn ?? optionalTools.humanizer),
62
66
  }
63
67
 
64
68
  const payload = {
@@ -0,0 +1,66 @@
1
+ import fse from 'fs-extra'
2
+ import path from 'node:path'
3
+ import { parse } from 'jsonc-parser'
4
+
5
+ /**
6
+ * Work out which optional tools a project actually has, rather than trusting
7
+ * the flags in harness.json.
8
+ *
9
+ * Those flags drift. Numa and Odissey both had codegraph and agentmemory MCP
10
+ * servers enabled in opencode.jsonc and the humanizer and simple-english skills
11
+ * installed, while harness.json recorded `codegraph: false`, `memory: false` and
12
+ * no humanizer key at all. patchGuardrails believed the config and emptied those
13
+ * marker sections, so agents lost the guidance for tools the project was running.
14
+ *
15
+ * Detection only ever turns a tool ON. A flag that is already true is left
16
+ * alone, because some of these install globally and cannot be seen from inside
17
+ * the repository at all.
18
+ */
19
+
20
+ function mcpServers(cwd) {
21
+ const configPath = path.join(cwd, 'opencode.jsonc')
22
+ if (!fse.pathExistsSync(configPath)) return {}
23
+ const errors = []
24
+ const parsed = parse(fse.readFileSync(configPath, 'utf-8'), errors)
25
+ if (errors.length > 0 || typeof parsed !== 'object' || parsed === null) return {}
26
+ return parsed.mcp ?? {}
27
+ }
28
+
29
+ function hasSkill(cwd, name) {
30
+ return fse.pathExistsSync(path.join(cwd, '.agents', 'skills', name))
31
+ }
32
+
33
+ /** An MCP server counts as present unless it is explicitly disabled. */
34
+ function hasMcp(servers, name) {
35
+ const server = servers[name]
36
+ if (!server) return false
37
+ return server.enabled !== false
38
+ }
39
+
40
+ export function detectInstalledTools(cwd = process.cwd()) {
41
+ const servers = mcpServers(cwd)
42
+ return {
43
+ // rtk is a global CLI with nothing to find in the repo, so it is not
44
+ // detectable here and the saved flag stands on its own.
45
+ codegraph: hasMcp(servers, 'codegraph') || fse.pathExistsSync(path.join(cwd, '.codegraph')),
46
+ memory: hasMcp(servers, 'agentmemory'),
47
+ simpleEnglish: hasSkill(cwd, 'simple-english'),
48
+ humanizer: hasSkill(cwd, 'humanizer'),
49
+ quota: fse.pathExistsSync(path.join(cwd, '.opencode', 'opencode-quota')),
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Saved flags merged with what is on disk. Detection can only add, so a tool
55
+ * recorded as enabled stays enabled even when nothing local proves it.
56
+ */
57
+ export function reconcileTools(saved = {}, cwd = process.cwd()) {
58
+ const detected = detectInstalledTools(cwd)
59
+ const merged = { ...saved }
60
+ for (const [key, present] of Object.entries(detected)) {
61
+ if (present) merged[key] = true
62
+ }
63
+ // simpleEnglish replaced caveman; honour the old key one last time.
64
+ if (saved.caveman) merged.simpleEnglish = true
65
+ return merged
66
+ }