@plainconceptsplatform/agent-harness 3.0.3 → 3.1.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.
@@ -1,112 +1,115 @@
1
- import { execa } from 'execa'
2
- import fse from 'fs-extra'
3
- import path from 'path'
4
- import { createRequire } from 'node:module'
5
- import { header, success, warn } from '../../utils/exec.js'
6
- import { configPath } from '../../utils/paths.js'
7
-
8
- const require = createRequire(import.meta.url)
9
- const { version: harnessVersion } = require('../../../package.json')
10
-
11
- function clampConcurrency(n) {
12
- const v = Number(n)
13
- if (!Number.isFinite(v)) return 3
14
- return Math.min(5, Math.max(1, Math.round(v)))
15
- }
16
-
17
- async function detectOpencodeVersion() {
18
- try {
19
- const result = await execa('opencode', ['--version'], { reject: false })
20
- if (result.exitCode !== 0) return null
21
- const output = (result.stdout || result.stderr || '').trim()
22
- return output || null
23
- } catch {
24
- return null
25
- }
26
- }
27
-
28
- export async function writeHarnessConfig(data) {
29
- header('Step 10, Writing onboarding metadata')
30
-
31
- const opencodeVersion = await detectOpencodeVersion()
32
- const cwd = data.cwd ?? process.cwd()
33
- const target = configPath(cwd)
34
-
35
- // Read the existing file so load-bearing config (models, maxConcurrent)
36
- // is preserved across metadata refreshes when not explicitly provided.
37
- const existing = await fse.readJson(target).catch(() => null)
38
-
39
- const selectedModels = Object.fromEntries(
40
- Object.entries({
41
- plan: data.planModel,
42
- build: data.buildModel,
43
- fast: data.fastModel,
44
- }).filter(([, value]) => value)
45
- )
46
- const models = Object.keys(selectedModels).length > 0
47
- ? { ...existing?.models, ...selectedModels }
48
- : existing?.models ?? null
49
- const maxConcurrent = clampConcurrency(data.maxConcurrentAgents ?? existing?.agents?.maxConcurrent ?? 3)
50
-
51
- const optionalTools = data.optionalTools ?? existing?.tools ?? {}
52
- const { caveman: _caveman, ...existingTools } = existing?.tools ?? {}
53
- const tools = {
54
- rtk: !!(optionalTools.rtk?.optedIn ?? optionalTools.rtk),
55
- quota: !!(optionalTools.quota?.optedIn ?? optionalTools.quota),
56
- simpleEnglish: !!(
57
- optionalTools.simpleEnglish?.optedIn ?? optionalTools.simpleEnglish ??
58
- optionalTools.caveman?.optedIn ?? optionalTools.caveman
59
- ),
60
- codegraph: !!(optionalTools.codegraph?.optedIn ?? optionalTools.codegraph),
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),
66
- }
67
-
68
- const payload = {
69
- ...existing,
70
- version: 2,
71
- generatedAt: new Date().toISOString(),
72
- harnessVersion,
73
- opencodeVersion,
74
-
75
- platform: {
76
- ...existing?.platform,
77
- backlog: data.backlogPlatform ?? 'none',
78
- repo: data.repoPlatform ?? 'none',
79
- },
80
-
81
- ...(models ? { models } : {}),
82
-
83
- agents: {
84
- ...existing?.agents,
85
- maxConcurrent,
86
- },
87
-
88
- source: {
89
- ...existing?.source,
90
- mode: data.sourceMode ?? 'current',
91
- roots: data.sourceRoots ?? [],
92
- },
93
-
94
- tools: { ...existingTools, ...tools },
95
-
96
- preexisting: {
97
- ...existing?.preexisting,
98
- design: !!data.hasDesign,
99
- architecture: !!data.hasArchitecture,
100
- openspec: !!data.hasOpenspec,
101
- },
102
- }
103
-
104
- try {
105
- await fse.ensureDir(path.dirname(target))
106
- await fse.writeJson(target, payload, { spaces: 2 })
107
- success('Wrote .opencode/harness.json')
108
- if (!opencodeVersion) warn('Could not detect opencode version, saved as null')
109
- } catch (err) {
110
- warn(`Could not write onboarding metadata: ${err.message}`)
111
- }
112
- }
1
+ import { execa } from 'execa'
2
+ import fse from 'fs-extra'
3
+ import path from 'path'
4
+ import { createRequire } from 'node:module'
5
+ import { header, success, warn } from '../../utils/exec.js'
6
+ import { configPath } from '../../utils/paths.js'
7
+
8
+ const require = createRequire(import.meta.url)
9
+ const { version: harnessVersion } = require('../../../package.json')
10
+
11
+ function clampConcurrency(n) {
12
+ const v = Number(n)
13
+ if (!Number.isFinite(v)) return 3
14
+ return Math.min(5, Math.max(1, Math.round(v)))
15
+ }
16
+
17
+ async function detectOpencodeVersion() {
18
+ try {
19
+ const result = await execa('opencode', ['--version'], { reject: false })
20
+ if (result.exitCode !== 0) return null
21
+ // `opencode --version` prints "opencode v2.0.22" on some builds and "2.0.22" on others;
22
+ // record the bare version so the field does not churn between machines.
23
+ const output = (result.stdout || result.stderr || '').trim()
24
+ return output.match(/\d+\.\d+\.\d+[\w.-]*/)?.[0] ?? (output || null)
25
+ } catch {
26
+ return null
27
+ }
28
+ }
29
+
30
+ export async function writeHarnessConfig(data) {
31
+ header('Step 10, Writing onboarding metadata')
32
+
33
+ const opencodeVersion = await detectOpencodeVersion()
34
+ const cwd = data.cwd ?? process.cwd()
35
+ const target = configPath(cwd)
36
+
37
+ // Read the existing file so load-bearing config (models, maxConcurrent)
38
+ // is preserved across metadata refreshes when not explicitly provided.
39
+ const existing = await fse.readJson(target).catch(() => null)
40
+
41
+ const selectedModels = Object.fromEntries(
42
+ Object.entries({
43
+ plan: data.planModel,
44
+ build: data.buildModel,
45
+ fast: data.fastModel,
46
+ }).filter(([, value]) => value)
47
+ )
48
+ const models = Object.keys(selectedModels).length > 0
49
+ ? { ...existing?.models, ...selectedModels }
50
+ : existing?.models ?? null
51
+ const maxConcurrent = clampConcurrency(data.maxConcurrentAgents ?? existing?.agents?.maxConcurrent ?? 3)
52
+
53
+ const optionalTools = data.optionalTools ?? existing?.tools ?? {}
54
+ const { caveman: _caveman, ...existingTools } = existing?.tools ?? {}
55
+ const tools = {
56
+ rtk: !!(optionalTools.rtk?.optedIn ?? optionalTools.rtk),
57
+ quota: !!(optionalTools.quota?.optedIn ?? optionalTools.quota),
58
+ simpleEnglish: !!(
59
+ optionalTools.simpleEnglish?.optedIn ?? optionalTools.simpleEnglish ??
60
+ optionalTools.caveman?.optedIn ?? optionalTools.caveman
61
+ ),
62
+ codegraph: !!(optionalTools.codegraph?.optedIn ?? optionalTools.codegraph),
63
+ memory: !!(optionalTools.memory?.optedIn ?? optionalTools.memory),
64
+ // humanizer was missing here, so a selection never survived a metadata
65
+ // write and patchGuardrails always saw it as off, leaving its marker
66
+ // section empty however the project was actually set up.
67
+ humanizer: !!(optionalTools.humanizer?.optedIn ?? optionalTools.humanizer),
68
+ }
69
+
70
+ const payload = {
71
+ ...existing,
72
+ // Never lower a schema version a repository already records.
73
+ version: Math.max(2, Number(existing?.version) || 0),
74
+ generatedAt: new Date().toISOString(),
75
+ harnessVersion,
76
+ opencodeVersion,
77
+
78
+ platform: {
79
+ ...existing?.platform,
80
+ backlog: data.backlogPlatform ?? 'none',
81
+ repo: data.repoPlatform ?? 'none',
82
+ },
83
+
84
+ ...(models ? { models } : {}),
85
+
86
+ agents: {
87
+ ...existing?.agents,
88
+ maxConcurrent,
89
+ },
90
+
91
+ source: {
92
+ ...existing?.source,
93
+ mode: data.sourceMode ?? 'current',
94
+ roots: data.sourceRoots ?? [],
95
+ },
96
+
97
+ tools: { ...existingTools, ...tools },
98
+
99
+ preexisting: {
100
+ ...existing?.preexisting,
101
+ design: !!data.hasDesign,
102
+ architecture: !!data.hasArchitecture,
103
+ openspec: !!data.hasOpenspec,
104
+ },
105
+ }
106
+
107
+ try {
108
+ await fse.ensureDir(path.dirname(target))
109
+ await fse.writeJson(target, payload, { spaces: 2 })
110
+ success('Wrote .opencode/harness.json')
111
+ if (!opencodeVersion) warn('Could not detect opencode version, saved as null')
112
+ } catch (err) {
113
+ warn(`Could not write onboarding metadata: ${err.message}`)
114
+ }
115
+ }
@@ -1,34 +1,38 @@
1
- import fse from 'fs-extra'
2
- import { applyEdits, modify, parse } from 'jsonc-parser'
3
- import path from 'path'
4
- import { success, warn } from '../../utils/exec.js'
5
-
6
- /**
7
- * Writes the selected build model as the default model in opencode.jsonc.
8
- * Per-tier models (plan/build/fast) are recorded in harness.json (models)
9
- * by the metadata step, then consumed by the agent-variant generator and /plan-apply.
10
- *
11
- * opencode tolerates JSONC (comments, trailing commas) in its config, so this
12
- * must not use readJson/writeJson: a strict parse crashes on valid configs and
13
- * a strict rewrite deletes user comments. jsonc-parser edits only the `model`
14
- * key and preserves the rest of the file byte-for-byte.
15
- */
16
- export async function writeModelsToConfigs({ buildModel, cwd = process.cwd() }) {
17
- const opencodeJsonPath = path.join(cwd, 'opencode.jsonc')
18
- if (!await fse.pathExists(opencodeJsonPath)) return
19
-
20
- const text = await fse.readFile(opencodeJsonPath, 'utf-8')
21
- const errors = []
22
- parse(text, errors)
23
- if (errors.length > 0) {
24
- warn('opencode.jsonc could not be parsed: leaving it untouched. Set "model" manually.')
25
- return
26
- }
27
-
28
- // `undefined` removes the key; a string value sets it.
29
- const edits = modify(text, ['model'], buildModel || undefined, {
30
- formattingOptions: { insertSpaces: true, tabSize: 2 },
31
- })
32
- await fse.writeFile(opencodeJsonPath, applyEdits(text, edits), 'utf-8')
33
- if (buildModel) success(`default model -> ${buildModel} (written to opencode.jsonc)`)
34
- }
1
+ import fse from 'fs-extra'
2
+ import { applyEdits, modify, parse } from 'jsonc-parser'
3
+ import path from 'path'
4
+ import { success, warn } from '../../utils/exec.js'
5
+
6
+ /**
7
+ * Writes the selected build model as the default model in opencode.jsonc.
8
+ * Per-tier models (plan/build/fast) are recorded in harness.json (models)
9
+ * by the metadata step, then consumed by the agent-variant generator and /plan-apply.
10
+ *
11
+ * opencode tolerates JSONC (comments, trailing commas) in its config, so this
12
+ * must not use readJson/writeJson: a strict parse crashes on valid configs and
13
+ * a strict rewrite deletes user comments. jsonc-parser edits only the `model`
14
+ * key and preserves the rest of the file byte-for-byte.
15
+ */
16
+ export async function writeModelsToConfigs({ buildModel, keepExisting = false, cwd = process.cwd() }) {
17
+ const opencodeJsonPath = path.join(cwd, 'opencode.jsonc')
18
+ if (!await fse.pathExists(opencodeJsonPath)) return
19
+
20
+ const text = await fse.readFile(opencodeJsonPath, 'utf-8')
21
+ const errors = []
22
+ parse(text, errors)
23
+ if (errors.length > 0) {
24
+ warn('opencode.jsonc could not be parsed: leaving it untouched. Set "model" manually.')
25
+ return
26
+ }
27
+
28
+ // A model the repository already chose is its decision, not the wizard's: `update` used to
29
+ // reset a deliberately pinned default to the build tier in every consumer it ran in.
30
+ if (keepExisting && typeof parse(text)?.model === 'string') return
31
+
32
+ // `undefined` removes the key; a string value sets it.
33
+ const edits = modify(text, ['model'], buildModel || undefined, {
34
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
35
+ })
36
+ await fse.writeFile(opencodeJsonPath, applyEdits(text, edits), 'utf-8')
37
+ if (buildModel) success(`default model -> ${buildModel} (written to opencode.jsonc)`)
38
+ }
@@ -4,7 +4,6 @@ import fse from 'fs-extra'
4
4
  import path from 'path'
5
5
  import { fileURLToPath } from 'url'
6
6
  import { code, commandExists, header, info, loading, success, warn } from '../../utils/exec.js'
7
- import { installQuota } from './quota.js'
8
7
  import { installSimpleEnglish } from './simple-english.js'
9
8
  import { installCodegraph } from './codegraph.js'
10
9
  import { installMemory } from './memory.js'
@@ -92,10 +91,6 @@ export async function tokenOptimizationStep(options = {}) {
92
91
  ? await checkRtk({ skipHeader: true, skipPrompt: true })
93
92
  : { optedIn: false, checked: false, available: false }
94
93
 
95
- const quota = has('quota')
96
- ? await installQuota({ skipHeader: true, skipPrompt: true })
97
- : { optedIn: false, installed: false }
98
-
99
94
  const simpleEnglish = has('simpleEnglish')
100
95
  ? await installSimpleEnglish({
101
96
  skipHeader: true,
@@ -132,7 +127,7 @@ export async function tokenOptimizationStep(options = {}) {
132
127
  if (selected.length === 0) warn('No token optimization tools selected')
133
128
  else success('Token optimization step completed')
134
129
 
135
- return { rtk, quota, simpleEnglish, codegraph, memory, humanizer }
130
+ return { rtk, simpleEnglish, codegraph, memory, humanizer }
136
131
  }
137
132
 
138
133
  async function syncSkillsLock() {
@@ -1,67 +1,80 @@
1
- import os from 'node:os'
2
- import path from 'node:path'
3
-
4
- /**
5
- * Every harness path that lands in a consumer repo, in one place.
6
- *
7
- * These filenames are a contract with every repo the harness has been
8
- * installed into: the CLI writes them, and the opencode plugins shipped in
9
- * harness/.opencode/plugins/ read them back at runtime. Changing a name
10
- * here means changing it in that payload too, or the plugins go blind.
11
- *
12
- * They are deliberately named for the harness rather than for the CLI, so a
13
- * future rename of the package does not become a rename of the on-disk state.
14
- */
15
- export const OPENCODE_DIR = '.opencode'
16
-
17
- /** Project config written by the metadata step. */
18
- export const CONFIG_FILE = 'harness.json'
19
-
20
- /** Per-developer model override, gitignored, never committed. */
21
- export const USER_CONFIG_FILE = 'harness.user.json'
22
-
23
- /** Hashes of every managed file, so `update` can detect local edits. */
24
- export const MANIFEST_FILE = 'harness-managed.json'
25
-
26
- /** Live subagent wave state, owned by the pc-subagent-monitor plugin. */
27
- export const RUN_STATE_FILE = 'harness-run.json'
28
-
29
- /** Names `.opencode/.gitignore` must cover. Mirrored in content/.opencode/_gitignore. */
30
- export const IGNORED_ENTRIES = [
31
- 'node_modules',
32
- RUN_STATE_FILE,
33
- USER_CONFIG_FILE,
34
- MANIFEST_FILE,
35
- 'source-roots.json',
36
- '*-engineer.*.md',
37
- // Regenerated from fullstack-engineer.md by pc-subagent-tiers every startup.
38
- 'agents/build.md',
39
- 'agents/plan.md',
40
- ]
41
-
42
- export function configPath(cwd = process.cwd()) {
43
- return path.join(cwd, OPENCODE_DIR, CONFIG_FILE)
44
- }
45
-
46
- export function userConfigPath(cwd = process.cwd()) {
47
- return path.join(cwd, OPENCODE_DIR, USER_CONFIG_FILE)
48
- }
49
-
50
- export function manifestPath(cwd = process.cwd()) {
51
- return path.join(cwd, OPENCODE_DIR, MANIFEST_FILE)
52
- }
53
-
54
- /** Cross-run models cache, keyed to the CLI rather than to any one repo. */
55
- export function modelsCacheDir() {
56
- return path.join(os.homedir(), '.config', 'agent-harness')
57
- }
58
-
59
- /**
60
- * Filenames written by opencode-onboard v1.x. Only used to detect a v1 repo
61
- * and refuse to half-patch it: nothing reads their contents.
62
- */
63
- export const LEGACY_CONFIG_FILES = [
64
- 'opencode-onboard.json',
65
- 'opencode-onboard.user.json',
66
- 'opencode-onboard-managed.json',
67
- ]
1
+ import os from 'node:os'
2
+ import path from 'node:path'
3
+
4
+ /**
5
+ * Every harness path that lands in a consumer repo, in one place.
6
+ *
7
+ * These filenames are a contract with every repo the harness has been
8
+ * installed into: the CLI writes them, and the opencode plugins shipped in
9
+ * harness/.opencode/plugins/ read them back at runtime. Changing a name
10
+ * here means changing it in that payload too, or the plugins go blind.
11
+ *
12
+ * They are deliberately named for the harness rather than for the CLI, so a
13
+ * future rename of the package does not become a rename of the on-disk state.
14
+ */
15
+ export const OPENCODE_DIR = '.opencode'
16
+
17
+ /** Project config written by the metadata step. */
18
+ export const CONFIG_FILE = 'harness.json'
19
+
20
+ /** Per-developer model override, gitignored, never committed. */
21
+ export const USER_CONFIG_FILE = 'harness.user.json'
22
+
23
+ /** Hashes of every managed file, so `update` can detect local edits. */
24
+ export const MANIFEST_FILE = 'harness-managed.json'
25
+
26
+ /** Live subagent wave state, owned by the pc-subagent-monitor plugin. */
27
+ export const RUN_STATE_FILE = 'harness-run.json'
28
+
29
+ /** Names `.opencode/.gitignore` must cover. Mirrored in content/.opencode/_gitignore. */
30
+ export const IGNORED_ENTRIES = [
31
+ 'node_modules',
32
+ RUN_STATE_FILE,
33
+ USER_CONFIG_FILE,
34
+ MANIFEST_FILE,
35
+ 'source-roots.json',
36
+ '*-engineer.*.md',
37
+ // Regenerated from fullstack-engineer.md by pc-subagent-tiers every startup.
38
+ 'agents/build.md',
39
+ 'agents/plan.md',
40
+ ]
41
+
42
+ /**
43
+ * Files the harness shipped and has since retired. `update` deletes them, because a skill or
44
+ * command left behind keeps being offered to agents long after the procedure it describes is
45
+ * gone. pc-ops-evidence wrote a capturePlan for a CI workflow that no longer exists; the
46
+ * verification plan from pc-repo-verify is what the evidence workflow replays now.
47
+ */
48
+ export const RETIRED_PATHS = [
49
+ '.agents/skills/pc-ops-evidence',
50
+ '.agents/skills/pc-make-evidence-scaffold',
51
+ '.opencode/commands/ops-evidence.md',
52
+ '.opencode/commands/make-evidence-scaffold.md',
53
+ ]
54
+
55
+ export function configPath(cwd = process.cwd()) {
56
+ return path.join(cwd, OPENCODE_DIR, CONFIG_FILE)
57
+ }
58
+
59
+ export function userConfigPath(cwd = process.cwd()) {
60
+ return path.join(cwd, OPENCODE_DIR, USER_CONFIG_FILE)
61
+ }
62
+
63
+ export function manifestPath(cwd = process.cwd()) {
64
+ return path.join(cwd, OPENCODE_DIR, MANIFEST_FILE)
65
+ }
66
+
67
+ /** Cross-run models cache, keyed to the CLI rather than to any one repo. */
68
+ export function modelsCacheDir() {
69
+ return path.join(os.homedir(), '.config', 'agent-harness')
70
+ }
71
+
72
+ /**
73
+ * Filenames written by opencode-onboard v1.x. Only used to detect a v1 repo
74
+ * and refuse to half-patch it: nothing reads their contents.
75
+ */
76
+ export const LEGACY_CONFIG_FILES = [
77
+ 'opencode-onboard.json',
78
+ 'opencode-onboard.user.json',
79
+ 'opencode-onboard-managed.json',
80
+ ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/agent-harness",
3
- "version": "3.0.3",
3
+ "version": "3.1.1",
4
4
  "description": "Installs the Plain Concepts Platform Harness into any codebase, and keeps it up to date. Wires OpenCode, OpenSpec, codegraph, and agentmemory into a multi-agent workflow that runs on native parallel subagents.",
5
5
  "keywords": [
6
6
  "opencode",
@@ -64,7 +64,11 @@ Tick `propose` when the proposal commit exists.
64
64
 
65
65
  Load `pc-plan-apply` in autonomous mode with `start_from: load-plan`. It owns worker resolution, subagent waves, commits, verification, and re-waves.
66
66
 
67
- Require it to return every task complete and `VERIFIED`, then load `pc-repo-verify`. Tick `apply` when apply returns `VERIFIED`; tick `verify` when `pc-repo-verify` returns `PLAN_WRITTEN` or `STUB_WRITTEN`.
67
+ Require it to return every task complete and `VERIFIED`, then load `pc-repo-verify`. Tick `apply` when apply returns `VERIFIED`. When `pc-repo-verify` returns `PLAN_WRITTEN` or `STUB_WRITTEN`, commit the plan, which the evidence workflow replays once the pull request merges, then tick `verify`:
68
+
69
+ ```bash
70
+ git add openspec/changes/{change-id}/verification-plan.md && git commit -m "verify: {title} ({change-id})"
71
+ ```
68
72
 
69
73
  ## Phase 5: Archive
70
74