@plainconceptsplatform/agent-harness 3.8.2 → 3.9.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.
Files changed (148) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +551 -549
  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 +48 -48
  36. package/cli/steps/clean/index.js +120 -120
  37. package/cli/steps/copy/agents.js +176 -160
  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 +2 -1
  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 +34 -345
  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 -49
  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 +12 -14
  110. package/skills/README.md +18 -18
  111. package/skills/agent-harness-cli/SKILL.md +86 -86
  112. package/skills/browser-automation/SKILL.md +72 -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 +64 -64
  147. package/skills/pc-userstory-gh/SKILL.md +63 -63
  148. package/skills/pc-userstory-jira/SKILL.md +74 -74
@@ -1,288 +1,288 @@
1
- // The same rules Project-Health uses to score a repository (collector/src/metrics/code-rules.ts).
2
- // Keep the two in step: a rule that differs makes the gate and the dashboard disagree.
3
-
4
- const SOURCE = /\.(?:[cm]?[jt]sx?|cs)$/
5
- const SKIP =
6
- /(?:^|\/)(?:node_modules|dist|build|out|\.next|bin|obj|coverage|Migrations|migrations|wwwroot|vendor|generated|__generated__|\.github|\.opencode|\.agents|\.claude|openspec)(?:\/|$)|(?:^|\/)components\/ui\/|\.d\.ts$|\.g\.cs$|\.Designer\.cs$|\.min\.js$|AssemblyInfo\.cs$/
7
- const TEST =
8
- /(?:^|\/)(?:tests?|__tests__|e2e|specs?)\/|\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)[\w.]+\.(?:Unit|Integration|E2E|Acceptance|Architecture)?Tests?\/|Tests?\.cs$/
9
- const GENERATED_HEADER =
10
- /Managed by @plainconceptsplatform|<auto-generated|@generated|This file was (?:auto-)?generated/i
11
-
12
- export function kindOf(path) {
13
- if (!SOURCE.test(path) || SKIP.test(path)) return null
14
- return TEST.test(path) ? 'test' : 'source'
15
- }
16
-
17
- export function isGenerated(text) {
18
- return GENERATED_HEADER.test(text.split('\n', 4).join('\n'))
19
- }
20
-
21
- // A comment that is an instruction to a tool, not prose for a reader. Semgrep demands a
22
- // `nosemgrep` justification on an empty catch; counting it against the comment budget made the
23
- // two gates trade lines with each other.
24
- const PRAGMA = /^(\/\/|\/\*)\s*(nosemgrep|biome-ignore|eslint-(disable|enable)|@ts-(expect-error|ignore)|prettier-ignore|(istanbul|c8|v8) ignore|stryker (disable|restore)|ReSharper (disable|restore)|NOSONAR)\b/i
25
-
26
- export function lineKinds(text) {
27
- const kinds = []
28
- let inBlock = false
29
- for (const raw of text.split('\n')) {
30
- const t = raw.trim()
31
- if (!t) kinds.push('blank')
32
- else if (!inBlock && PRAGMA.test(t)) {
33
- kinds.push('pragma')
34
- inBlock = t.startsWith('/*') && !t.includes('*/')
35
- } else if (inBlock) {
36
- kinds.push('comment')
37
- if (t.includes('*/')) inBlock = false
38
- } else if (t.startsWith('/*')) {
39
- kinds.push('comment')
40
- inBlock = !t.includes('*/')
41
- } else if (t.startsWith('//')) kinds.push('comment')
42
- else kinds.push('code')
43
- }
44
- return kinds
45
- }
46
-
47
- export function countLines(text) {
48
- let codeLines = 0
49
- let commentLines = 0
50
- for (const kind of lineKinds(text)) {
51
- if (kind === 'code') codeLines += 1
52
- else if (kind === 'comment') commentLines += 1
53
- }
54
- return { codeLines, commentLines }
55
- }
56
-
57
- const FILLER = new Set(
58
- 'a an the this that of for to in on is are be or and with by from its it get gets set sets getting setting value values instance class record struct interface enum new initializes initialises creates constructor property field method current specified given provided object function component hook type props'.split(' '),
59
- )
60
- const RETURNS_FILLER = new Set([
61
- ...FILLER,
62
- 'result', 'results', 'task', 'representing', 'asynchronous', 'async', 'operation', 'returns', 'returned', 'which', 'that',
63
- ])
64
-
65
- const words = text =>
66
- text
67
- .replace(/<see\s+cref="([^"]+)"\s*\/>/g, ' $1 ')
68
- .replace(/<paramref\s+name="([^"]+)"\s*\/>/g, ' $1 ')
69
- .replace(/\{@link\s+([^}\s]+)[^}]*\}/g, ' $1 ')
70
- .replace(/<[^>]+>/g, ' ')
71
- .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
72
- .replace(/([A-Z])([A-Z][a-z])/g, '$1 $2')
73
- .toLowerCase()
74
- .split(/[^a-z0-9]+/)
75
- .filter(Boolean)
76
-
77
- const stem = w => (w.length > 3 && w.endsWith('s') && !w.endsWith('ss') ? w.slice(0, -1) : w)
78
-
79
- export function restates(text, identifier, filler = FILLER) {
80
- const name = new Set(words(identifier.replace(/^I(?=[A-Z])/, '')).map(stem))
81
- const said = words(text).filter(w => !filler.has(w) && !filler.has(stem(w))).map(stem)
82
- return said.every(w => name.has(w))
83
- }
84
-
85
- const DECL_KEYWORD = /\b(?:class|record|struct|interface|enum|namespace|type|function)\s+([A-Za-z_$][\w$]*)/
86
- const DECL_VAR = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)/
87
- const DECL_CALL = /([A-Za-z_$][\w$]*)\s*(?:<[^()]*>)?\s*\(/
88
- const DECL_PROP = /([A-Za-z_$][\w$]*)\s*[?!]?\s*(?:\{|=>|=|;|:|$)/
89
-
90
- export function declaredName(line) {
91
- const l = line.trim()
92
- if (!l || l.startsWith('[') || l.startsWith('@')) return null
93
- return (l.match(DECL_KEYWORD) ?? l.match(DECL_VAR) ?? l.match(DECL_CALL) ?? l.match(DECL_PROP))?.[1] ?? null
94
- }
95
-
96
- const BANNER = /^\/\/+\s*(?:([=\-*#~_+])\1{2,}.*|.*([=\-*#~_+])\2{2,})\s*$/
97
- const REGION = /^#(?:region|endregion)\b/
98
- const TODO = /\/\/.*\b(?:TODO|FIXME|HACK|XXX)\b/
99
- const TRACKED = /#\d+|https?:\/\/|\b[A-Z][A-Z0-9]+-\d+\b|\(\s*issue\s/i
100
- const APOLOGETIC =
101
- /\b(?:not sure (?:if|whether|this|why)|hope this works|might not work|probably wrong|no idea why|i think this (?:works|is right)|quick and dirty)\b/i
102
- const CODE_BODY =
103
- /^(?:(?:var|let|const|await|return|using|import|export|if|else|for|foreach|while|switch|throw|new|public|private|protected|internal|static|async)\b.*[;{(]\s*$|[\w.$[\]]+\s*(?:\+\+|--|[+\-*/]?=)\s*[^=].*;\s*$|[\w.$]+(?:<[^>]*>)?\([^)]*\)\s*;\s*$|[{}]\s*;?\s*$|\}\s*else\b.*$)/
104
- const TOOL_PRAGMA =
105
- /^(?:nosemgrep|eslint|biome|jscpd|@ts-|prettier|istanbul|c8|v8|webpack|vite|ReSharper|resharper|language=|<auto-generated|#pragma)/
106
-
107
- const bodyOf = trimmed => trimmed.replace(/^\/\/+\s?/, '')
108
-
109
- export function trailingComment(line) {
110
- let quote = null
111
- for (let i = 0; i < line.length; i++) {
112
- const c = line[i]
113
- if (quote) {
114
- if (c === '\\') i += 1
115
- else if (c === quote) quote = null
116
- } else if (c === "'" || c === '"' || c === '`') quote = c
117
- else if (c === '/' && line[i + 1] === '/' && line[i - 1] !== ':') return line.slice(i)
118
- }
119
- return null
120
- }
121
-
122
- export function slopFindings(text) {
123
- const lines = text.split('\n')
124
- const out = []
125
- const add = (rule, line) => out.push({ rule, line })
126
- const nameAfter = from => {
127
- for (let k = from; k < lines.length && k < from + 6; k++) {
128
- const name = declaredName(lines[k])
129
- if (name) return name
130
- }
131
- return null
132
- }
133
- let codeRun = false
134
- for (let i = 0; i < lines.length; i++) {
135
- const t = lines[i].trim()
136
- const isLineComment = t.startsWith('//') && !t.startsWith('///')
137
- const body = isLineComment ? bodyOf(t).trim() : ''
138
- const isCode =
139
- isLineComment && body !== '' && !TOOL_PRAGMA.test(body) && CODE_BODY.test(body) && !/\s(?:https?:)?\/\//.test(body)
140
- if (isCode && !codeRun) add('commented-out-code', i + 1)
141
- codeRun = isCode || (codeRun && isLineComment && !body)
142
-
143
- if (isLineComment && BANNER.test(t)) add('banner', i + 1)
144
- if (REGION.test(t)) add('region', i + 1)
145
- const comment = trailingComment(t)
146
- if (comment && TODO.test(comment) && !TRACKED.test(comment)) add('untracked-todo', i + 1)
147
- if ((t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) && APOLOGETIC.test(t)) add('apologetic', i + 1)
148
-
149
- if (t.startsWith('///') && t.includes('<summary>')) {
150
- const start = i
151
- let doc = ''
152
- let j = i
153
- for (; j < lines.length && lines[j].trim().startsWith('///'); j++) {
154
- doc += ` ${lines[j].trim().replace(/^\/\/\/\s?/, '')}`
155
- }
156
- const name = nameAfter(j)
157
- const summary = doc.match(/<summary>([\s\S]*?)<\/summary>/)?.[1] ?? ''
158
- if (name && summary.trim() && restates(summary, name)) add('restating-summary', start + 1)
159
- for (const m of doc.matchAll(/<param\s+name="([^"]+)"\s*>([\s\S]*?)<\/param>/g)) {
160
- if (m[2].trim() && restates(m[2], m[1])) add('restating-param', start + 1)
161
- }
162
- for (const m of doc.matchAll(/<returns>([\s\S]*?)<\/returns>/g)) {
163
- if (m[1].trim() && restates(m[1], '', RETURNS_FILLER)) add('filler-returns', start + 1)
164
- }
165
- i = j - 1
166
- } else if (t.startsWith('/**') && !t.startsWith('/***')) {
167
- const start = i
168
- let j = i
169
- let doc = ''
170
- for (; j < lines.length; j++) {
171
- doc += ` ${lines[j].trim()}`
172
- if (lines[j].includes('*/')) break
173
- }
174
- const text = doc.replace(/^\s*\/\*\*|\*\/\s*$/g, ' ').replace(/\s\*\s?/g, ' ').trim()
175
- const summary = text.split(/\s@\w+/)[0]?.trim() ?? ''
176
- const name = nameAfter(j + 1)
177
- if (name && summary && restates(summary, name)) add('restating-jsdoc', start + 1)
178
- for (const m of text.matchAll(/@param\s+(?:\{[^}]*\}\s*)?\[?([\w$.]+)\]?\s*(?:-\s*)?([^@]*)/g)) {
179
- if (m[2].trim() && restates(m[2], m[1])) add('restating-param', start + 1)
180
- }
181
- for (const m of text.matchAll(/@returns?\s+(?:\{[^}]*\}\s*)?([^@]*)/g)) {
182
- if (m[1].trim() && restates(m[1], '', RETURNS_FILLER)) add('filler-returns', start + 1)
183
- }
184
- i = j
185
- }
186
- }
187
- return out
188
- }
189
-
190
- const ISSUE_REF = /(?:\b[A-Z][\w-]*\s)?#\d+\b|\b[A-Z][A-Z0-9]+-\d+\b/
191
- const PAST =
192
- /\b(?:was|were|used to|previously|before|had|broke|regressed|ended up|turned out|once|anymore|no longer|caused)\b/i
193
- const TS_INTERNAL = /^(?:async\s+)?(?:function|const|let|var|class|interface|type|enum)\b/
194
- const CS_INTERNAL = /^(?:private|internal|private\s+protected)\b/
195
-
196
- export function commentBlocks(text) {
197
- const lines = text.split('\n')
198
- const blocks = []
199
- const firstCode = lines.findIndex(l => {
200
- const t = l.trim()
201
- return t !== '' && !t.startsWith('//') && !t.startsWith('/*') && !t.startsWith('*')
202
- })
203
- let i = 0
204
- while (i < lines.length) {
205
- const t = lines[i].trim()
206
- if (!(t.startsWith('//') || t.startsWith('/*'))) {
207
- i += 1
208
- continue
209
- }
210
- const start = i
211
- let inBlock = false
212
- let body = ''
213
- for (; i < lines.length; i++) {
214
- const s = lines[i].trim()
215
- if (inBlock) {
216
- body += ` ${s}`
217
- if (s.includes('*/')) inBlock = false
218
- continue
219
- }
220
- if (s.startsWith('/*')) {
221
- body += ` ${s}`
222
- inBlock = !s.includes('*/')
223
- continue
224
- }
225
- if (s.startsWith('//')) {
226
- body += ` ${s}`
227
- continue
228
- }
229
- break
230
- }
231
- const rules = []
232
- if (ISSUE_REF.test(body) && PAST.test(body)) rules.push('history')
233
- if (i - start >= 5 && start > firstCode) rules.push('essay')
234
- if (body.trimStart().startsWith('/**') || body.trimStart().startsWith('///')) {
235
- const next = lines.slice(i, i + 4).find(l => {
236
- const s = l.trim()
237
- return s !== '' && !s.startsWith('[') && !s.startsWith('@')
238
- })
239
- const decl = next?.trim() ?? ''
240
- if (TS_INTERNAL.test(decl) || CS_INTERNAL.test(decl)) rules.push('internal-doc')
241
- }
242
- blocks.push({ start: start + 1, end: i, rules })
243
- }
244
- return blocks
245
- }
246
-
247
- const TS_CASE =
248
- /(?:^|[^\w.$])(x|f)?(?:it|test)(\.only|\.skip|\.todo|\.concurrent|\.each\s*(?:\([^)]*\)|`[^`]*`))?\s*\(\s*['"`]/g
249
- const CS_CASE = /\[(?:Fact|Theory|SkippableFact|SkippableTheory|Test|TestMethod)\b([^\]]*)\]/g
250
- const TS_ASSERT = /\b(?:expect|assert)\w*(?:\.\w+)?\s*\(|\.should\b/g
251
- const TS_CALL_ASSERT = /\.(?:not\.)?(?:toHaveBeenCalled\w*|toBeCalled\w*|toHaveBeenNthCalledWith)\s*\(/g
252
- const CS_ASSERT = /\bAssert\w*\.\w+|\.Should\w*\(|\bVerify\w*\(|\bThrows\w*(?:Async)?\s*[<(]|\.Received\(/g
253
- const CS_CALL_ASSERT = /\.Received\(|\.DidNotReceive\(|\.Verify\(\s*\w+\s*=>/g
254
- const SNAPSHOT = /\.toMatch(?:Inline|File)?Snapshot\s*\(|\bawait\s+Verify(?:Json|Xml)?\s*\(/
255
-
256
- const count = (text, re) => (text.match(re) ?? []).length
257
-
258
- export function testStats(path, text) {
259
- const cs = path.endsWith('.cs')
260
- const starts = []
261
- for (const m of text.matchAll(cs ? CS_CASE : TS_CASE)) {
262
- if (cs) {
263
- starts.push({ index: m.index, skipped: /\bSkip\s*=/.test(m[1] ?? ''), focused: false })
264
- } else {
265
- const modifier = m[2] ?? ''
266
- if (modifier === '.todo') continue
267
- starts.push({ index: m.index, skipped: m[1] === 'x' || modifier === '.skip', focused: m[1] === 'f' || modifier === '.only' })
268
- }
269
- }
270
- const stats = { cases: starts.length, skipped: 0, focused: 0, snapshot: 0, noAssertion: 0, mockOnly: 0 }
271
- if (cs) stats.skipped += count(text, /\[Ignore\b/g)
272
- else {
273
- stats.skipped += count(text, /\b(?:describe\.skip|xdescribe)\s*\(/g)
274
- stats.focused += count(text, /\b(?:describe\.only|fdescribe)\s*\(/g)
275
- }
276
- for (let k = 0; k < starts.length; k++) {
277
- const s = starts[k]
278
- const segment = text.slice(s.index, starts[k + 1]?.index ?? text.length)
279
- if (s.skipped) stats.skipped += 1
280
- if (s.focused) stats.focused += 1
281
- if (SNAPSHOT.test(segment)) stats.snapshot += 1
282
- const asserts = count(segment, cs ? CS_ASSERT : TS_ASSERT)
283
- const callAsserts = count(segment, cs ? CS_CALL_ASSERT : TS_CALL_ASSERT)
284
- if (asserts === 0) stats.noAssertion += 1
285
- else if (callAsserts > 0 && callAsserts >= asserts) stats.mockOnly += 1
286
- }
287
- return stats
288
- }
1
+ // The same rules Project-Health uses to score a repository (collector/src/metrics/code-rules.ts).
2
+ // Keep the two in step: a rule that differs makes the gate and the dashboard disagree.
3
+
4
+ const SOURCE = /\.(?:[cm]?[jt]sx?|cs)$/
5
+ const SKIP =
6
+ /(?:^|\/)(?:node_modules|dist|build|out|\.next|bin|obj|coverage|Migrations|migrations|wwwroot|vendor|generated|__generated__|\.github|\.opencode|\.agents|\.claude|openspec)(?:\/|$)|(?:^|\/)components\/ui\/|\.d\.ts$|\.g\.cs$|\.Designer\.cs$|\.min\.js$|AssemblyInfo\.cs$/
7
+ const TEST =
8
+ /(?:^|\/)(?:tests?|__tests__|e2e|specs?)\/|\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)[\w.]+\.(?:Unit|Integration|E2E|Acceptance|Architecture)?Tests?\/|Tests?\.cs$/
9
+ const GENERATED_HEADER =
10
+ /Managed by @plainconceptsplatform|<auto-generated|@generated|This file was (?:auto-)?generated/i
11
+
12
+ export function kindOf(path) {
13
+ if (!SOURCE.test(path) || SKIP.test(path)) return null
14
+ return TEST.test(path) ? 'test' : 'source'
15
+ }
16
+
17
+ export function isGenerated(text) {
18
+ return GENERATED_HEADER.test(text.split('\n', 4).join('\n'))
19
+ }
20
+
21
+ // A comment that is an instruction to a tool, not prose for a reader. Semgrep demands a
22
+ // `nosemgrep` justification on an empty catch; counting it against the comment budget made the
23
+ // two gates trade lines with each other.
24
+ const PRAGMA = /^(\/\/|\/\*)\s*(nosemgrep|biome-ignore|eslint-(disable|enable)|@ts-(expect-error|ignore)|prettier-ignore|(istanbul|c8|v8) ignore|stryker (disable|restore)|ReSharper (disable|restore)|NOSONAR)\b/i
25
+
26
+ export function lineKinds(text) {
27
+ const kinds = []
28
+ let inBlock = false
29
+ for (const raw of text.split('\n')) {
30
+ const t = raw.trim()
31
+ if (!t) kinds.push('blank')
32
+ else if (!inBlock && PRAGMA.test(t)) {
33
+ kinds.push('pragma')
34
+ inBlock = t.startsWith('/*') && !t.includes('*/')
35
+ } else if (inBlock) {
36
+ kinds.push('comment')
37
+ if (t.includes('*/')) inBlock = false
38
+ } else if (t.startsWith('/*')) {
39
+ kinds.push('comment')
40
+ inBlock = !t.includes('*/')
41
+ } else if (t.startsWith('//')) kinds.push('comment')
42
+ else kinds.push('code')
43
+ }
44
+ return kinds
45
+ }
46
+
47
+ export function countLines(text) {
48
+ let codeLines = 0
49
+ let commentLines = 0
50
+ for (const kind of lineKinds(text)) {
51
+ if (kind === 'code') codeLines += 1
52
+ else if (kind === 'comment') commentLines += 1
53
+ }
54
+ return { codeLines, commentLines }
55
+ }
56
+
57
+ const FILLER = new Set(
58
+ 'a an the this that of for to in on is are be or and with by from its it get gets set sets getting setting value values instance class record struct interface enum new initializes initialises creates constructor property field method current specified given provided object function component hook type props'.split(' '),
59
+ )
60
+ const RETURNS_FILLER = new Set([
61
+ ...FILLER,
62
+ 'result', 'results', 'task', 'representing', 'asynchronous', 'async', 'operation', 'returns', 'returned', 'which', 'that',
63
+ ])
64
+
65
+ const words = text =>
66
+ text
67
+ .replace(/<see\s+cref="([^"]+)"\s*\/>/g, ' $1 ')
68
+ .replace(/<paramref\s+name="([^"]+)"\s*\/>/g, ' $1 ')
69
+ .replace(/\{@link\s+([^}\s]+)[^}]*\}/g, ' $1 ')
70
+ .replace(/<[^>]+>/g, ' ')
71
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
72
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1 $2')
73
+ .toLowerCase()
74
+ .split(/[^a-z0-9]+/)
75
+ .filter(Boolean)
76
+
77
+ const stem = w => (w.length > 3 && w.endsWith('s') && !w.endsWith('ss') ? w.slice(0, -1) : w)
78
+
79
+ export function restates(text, identifier, filler = FILLER) {
80
+ const name = new Set(words(identifier.replace(/^I(?=[A-Z])/, '')).map(stem))
81
+ const said = words(text).filter(w => !filler.has(w) && !filler.has(stem(w))).map(stem)
82
+ return said.every(w => name.has(w))
83
+ }
84
+
85
+ const DECL_KEYWORD = /\b(?:class|record|struct|interface|enum|namespace|type|function)\s+([A-Za-z_$][\w$]*)/
86
+ const DECL_VAR = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)/
87
+ const DECL_CALL = /([A-Za-z_$][\w$]*)\s*(?:<[^()]*>)?\s*\(/
88
+ const DECL_PROP = /([A-Za-z_$][\w$]*)\s*[?!]?\s*(?:\{|=>|=|;|:|$)/
89
+
90
+ export function declaredName(line) {
91
+ const l = line.trim()
92
+ if (!l || l.startsWith('[') || l.startsWith('@')) return null
93
+ return (l.match(DECL_KEYWORD) ?? l.match(DECL_VAR) ?? l.match(DECL_CALL) ?? l.match(DECL_PROP))?.[1] ?? null
94
+ }
95
+
96
+ const BANNER = /^\/\/+\s*(?:([=\-*#~_+])\1{2,}.*|.*([=\-*#~_+])\2{2,})\s*$/
97
+ const REGION = /^#(?:region|endregion)\b/
98
+ const TODO = /\/\/.*\b(?:TODO|FIXME|HACK|XXX)\b/
99
+ const TRACKED = /#\d+|https?:\/\/|\b[A-Z][A-Z0-9]+-\d+\b|\(\s*issue\s/i
100
+ const APOLOGETIC =
101
+ /\b(?:not sure (?:if|whether|this|why)|hope this works|might not work|probably wrong|no idea why|i think this (?:works|is right)|quick and dirty)\b/i
102
+ const CODE_BODY =
103
+ /^(?:(?:var|let|const|await|return|using|import|export|if|else|for|foreach|while|switch|throw|new|public|private|protected|internal|static|async)\b.*[;{(]\s*$|[\w.$[\]]+\s*(?:\+\+|--|[+\-*/]?=)\s*[^=].*;\s*$|[\w.$]+(?:<[^>]*>)?\([^)]*\)\s*;\s*$|[{}]\s*;?\s*$|\}\s*else\b.*$)/
104
+ const TOOL_PRAGMA =
105
+ /^(?:nosemgrep|eslint|biome|jscpd|@ts-|prettier|istanbul|c8|v8|webpack|vite|ReSharper|resharper|language=|<auto-generated|#pragma)/
106
+
107
+ const bodyOf = trimmed => trimmed.replace(/^\/\/+\s?/, '')
108
+
109
+ export function trailingComment(line) {
110
+ let quote = null
111
+ for (let i = 0; i < line.length; i++) {
112
+ const c = line[i]
113
+ if (quote) {
114
+ if (c === '\\') i += 1
115
+ else if (c === quote) quote = null
116
+ } else if (c === "'" || c === '"' || c === '`') quote = c
117
+ else if (c === '/' && line[i + 1] === '/' && line[i - 1] !== ':') return line.slice(i)
118
+ }
119
+ return null
120
+ }
121
+
122
+ export function slopFindings(text) {
123
+ const lines = text.split('\n')
124
+ const out = []
125
+ const add = (rule, line) => out.push({ rule, line })
126
+ const nameAfter = from => {
127
+ for (let k = from; k < lines.length && k < from + 6; k++) {
128
+ const name = declaredName(lines[k])
129
+ if (name) return name
130
+ }
131
+ return null
132
+ }
133
+ let codeRun = false
134
+ for (let i = 0; i < lines.length; i++) {
135
+ const t = lines[i].trim()
136
+ const isLineComment = t.startsWith('//') && !t.startsWith('///')
137
+ const body = isLineComment ? bodyOf(t).trim() : ''
138
+ const isCode =
139
+ isLineComment && body !== '' && !TOOL_PRAGMA.test(body) && CODE_BODY.test(body) && !/\s(?:https?:)?\/\//.test(body)
140
+ if (isCode && !codeRun) add('commented-out-code', i + 1)
141
+ codeRun = isCode || (codeRun && isLineComment && !body)
142
+
143
+ if (isLineComment && BANNER.test(t)) add('banner', i + 1)
144
+ if (REGION.test(t)) add('region', i + 1)
145
+ const comment = trailingComment(t)
146
+ if (comment && TODO.test(comment) && !TRACKED.test(comment)) add('untracked-todo', i + 1)
147
+ if ((t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) && APOLOGETIC.test(t)) add('apologetic', i + 1)
148
+
149
+ if (t.startsWith('///') && t.includes('<summary>')) {
150
+ const start = i
151
+ let doc = ''
152
+ let j = i
153
+ for (; j < lines.length && lines[j].trim().startsWith('///'); j++) {
154
+ doc += ` ${lines[j].trim().replace(/^\/\/\/\s?/, '')}`
155
+ }
156
+ const name = nameAfter(j)
157
+ const summary = doc.match(/<summary>([\s\S]*?)<\/summary>/)?.[1] ?? ''
158
+ if (name && summary.trim() && restates(summary, name)) add('restating-summary', start + 1)
159
+ for (const m of doc.matchAll(/<param\s+name="([^"]+)"\s*>([\s\S]*?)<\/param>/g)) {
160
+ if (m[2].trim() && restates(m[2], m[1])) add('restating-param', start + 1)
161
+ }
162
+ for (const m of doc.matchAll(/<returns>([\s\S]*?)<\/returns>/g)) {
163
+ if (m[1].trim() && restates(m[1], '', RETURNS_FILLER)) add('filler-returns', start + 1)
164
+ }
165
+ i = j - 1
166
+ } else if (t.startsWith('/**') && !t.startsWith('/***')) {
167
+ const start = i
168
+ let j = i
169
+ let doc = ''
170
+ for (; j < lines.length; j++) {
171
+ doc += ` ${lines[j].trim()}`
172
+ if (lines[j].includes('*/')) break
173
+ }
174
+ const text = doc.replace(/^\s*\/\*\*|\*\/\s*$/g, ' ').replace(/\s\*\s?/g, ' ').trim()
175
+ const summary = text.split(/\s@\w+/)[0]?.trim() ?? ''
176
+ const name = nameAfter(j + 1)
177
+ if (name && summary && restates(summary, name)) add('restating-jsdoc', start + 1)
178
+ for (const m of text.matchAll(/@param\s+(?:\{[^}]*\}\s*)?\[?([\w$.]+)\]?\s*(?:-\s*)?([^@]*)/g)) {
179
+ if (m[2].trim() && restates(m[2], m[1])) add('restating-param', start + 1)
180
+ }
181
+ for (const m of text.matchAll(/@returns?\s+(?:\{[^}]*\}\s*)?([^@]*)/g)) {
182
+ if (m[1].trim() && restates(m[1], '', RETURNS_FILLER)) add('filler-returns', start + 1)
183
+ }
184
+ i = j
185
+ }
186
+ }
187
+ return out
188
+ }
189
+
190
+ const ISSUE_REF = /(?:\b[A-Z][\w-]*\s)?#\d+\b|\b[A-Z][A-Z0-9]+-\d+\b/
191
+ const PAST =
192
+ /\b(?:was|were|used to|previously|before|had|broke|regressed|ended up|turned out|once|anymore|no longer|caused)\b/i
193
+ const TS_INTERNAL = /^(?:async\s+)?(?:function|const|let|var|class|interface|type|enum)\b/
194
+ const CS_INTERNAL = /^(?:private|internal|private\s+protected)\b/
195
+
196
+ export function commentBlocks(text) {
197
+ const lines = text.split('\n')
198
+ const blocks = []
199
+ const firstCode = lines.findIndex(l => {
200
+ const t = l.trim()
201
+ return t !== '' && !t.startsWith('//') && !t.startsWith('/*') && !t.startsWith('*')
202
+ })
203
+ let i = 0
204
+ while (i < lines.length) {
205
+ const t = lines[i].trim()
206
+ if (!(t.startsWith('//') || t.startsWith('/*'))) {
207
+ i += 1
208
+ continue
209
+ }
210
+ const start = i
211
+ let inBlock = false
212
+ let body = ''
213
+ for (; i < lines.length; i++) {
214
+ const s = lines[i].trim()
215
+ if (inBlock) {
216
+ body += ` ${s}`
217
+ if (s.includes('*/')) inBlock = false
218
+ continue
219
+ }
220
+ if (s.startsWith('/*')) {
221
+ body += ` ${s}`
222
+ inBlock = !s.includes('*/')
223
+ continue
224
+ }
225
+ if (s.startsWith('//')) {
226
+ body += ` ${s}`
227
+ continue
228
+ }
229
+ break
230
+ }
231
+ const rules = []
232
+ if (ISSUE_REF.test(body) && PAST.test(body)) rules.push('history')
233
+ if (i - start >= 5 && start > firstCode) rules.push('essay')
234
+ if (body.trimStart().startsWith('/**') || body.trimStart().startsWith('///')) {
235
+ const next = lines.slice(i, i + 4).find(l => {
236
+ const s = l.trim()
237
+ return s !== '' && !s.startsWith('[') && !s.startsWith('@')
238
+ })
239
+ const decl = next?.trim() ?? ''
240
+ if (TS_INTERNAL.test(decl) || CS_INTERNAL.test(decl)) rules.push('internal-doc')
241
+ }
242
+ blocks.push({ start: start + 1, end: i, rules })
243
+ }
244
+ return blocks
245
+ }
246
+
247
+ const TS_CASE =
248
+ /(?:^|[^\w.$])(x|f)?(?:it|test)(\.only|\.skip|\.todo|\.concurrent|\.each\s*(?:\([^)]*\)|`[^`]*`))?\s*\(\s*['"`]/g
249
+ const CS_CASE = /\[(?:Fact|Theory|SkippableFact|SkippableTheory|Test|TestMethod)\b([^\]]*)\]/g
250
+ const TS_ASSERT = /\b(?:expect|assert)\w*(?:\.\w+)?\s*\(|\.should\b/g
251
+ const TS_CALL_ASSERT = /\.(?:not\.)?(?:toHaveBeenCalled\w*|toBeCalled\w*|toHaveBeenNthCalledWith)\s*\(/g
252
+ const CS_ASSERT = /\bAssert\w*\.\w+|\.Should\w*\(|\bVerify\w*\(|\bThrows\w*(?:Async)?\s*[<(]|\.Received\(/g
253
+ const CS_CALL_ASSERT = /\.Received\(|\.DidNotReceive\(|\.Verify\(\s*\w+\s*=>/g
254
+ const SNAPSHOT = /\.toMatch(?:Inline|File)?Snapshot\s*\(|\bawait\s+Verify(?:Json|Xml)?\s*\(/
255
+
256
+ const count = (text, re) => (text.match(re) ?? []).length
257
+
258
+ export function testStats(path, text) {
259
+ const cs = path.endsWith('.cs')
260
+ const starts = []
261
+ for (const m of text.matchAll(cs ? CS_CASE : TS_CASE)) {
262
+ if (cs) {
263
+ starts.push({ index: m.index, skipped: /\bSkip\s*=/.test(m[1] ?? ''), focused: false })
264
+ } else {
265
+ const modifier = m[2] ?? ''
266
+ if (modifier === '.todo') continue
267
+ starts.push({ index: m.index, skipped: m[1] === 'x' || modifier === '.skip', focused: m[1] === 'f' || modifier === '.only' })
268
+ }
269
+ }
270
+ const stats = { cases: starts.length, skipped: 0, focused: 0, snapshot: 0, noAssertion: 0, mockOnly: 0 }
271
+ if (cs) stats.skipped += count(text, /\[Ignore\b/g)
272
+ else {
273
+ stats.skipped += count(text, /\b(?:describe\.skip|xdescribe)\s*\(/g)
274
+ stats.focused += count(text, /\b(?:describe\.only|fdescribe)\s*\(/g)
275
+ }
276
+ for (let k = 0; k < starts.length; k++) {
277
+ const s = starts[k]
278
+ const segment = text.slice(s.index, starts[k + 1]?.index ?? text.length)
279
+ if (s.skipped) stats.skipped += 1
280
+ if (s.focused) stats.focused += 1
281
+ if (SNAPSHOT.test(segment)) stats.snapshot += 1
282
+ const asserts = count(segment, cs ? CS_ASSERT : TS_ASSERT)
283
+ const callAsserts = count(segment, cs ? CS_CALL_ASSERT : TS_CALL_ASSERT)
284
+ if (asserts === 0) stats.noAssertion += 1
285
+ else if (callAsserts > 0 && callAsserts >= asserts) stats.mockOnly += 1
286
+ }
287
+ return stats
288
+ }
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ // Print the effective model routing and the generated agents. --strict exits 1
3
+ // on any warning, a missing planner-sub, or a manifest older than its configs.
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+
8
+ const args = process.argv.slice(2);
9
+ const rootIndex = args.indexOf("--root");
10
+ const root = rootIndex !== -1 ? path.resolve(args[rootIndex + 1]) : process.cwd();
11
+ const strict = args.includes("--strict");
12
+ const manifestPath = path.join(root, ".opencode", "harness-models.json");
13
+
14
+ function fail(message, code = 1) {
15
+ console.error(message);
16
+ process.exit(code);
17
+ }
18
+
19
+ if (!fs.existsSync(manifestPath)) {
20
+ fail(`harness-doctor: ${manifestPath} is missing; run node .opencode/scripts/sync-agents.mjs`, 2);
21
+ }
22
+
23
+ let manifest;
24
+ try {
25
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
26
+ } catch (error) {
27
+ fail(`harness-doctor: ${manifestPath} is not valid JSON: ${error.message}`, 2);
28
+ }
29
+
30
+ const models = manifest.models ?? {};
31
+ const sources = manifest.source ?? {};
32
+ const warnings = Array.isArray(manifest.warnings) ? manifest.warnings : [];
33
+ const agents = manifest.agents ?? {};
34
+ const planner = agents["planner-sub"] ?? agents.planner;
35
+ const plannerFile = planner?.file ? path.join(root, ".opencode", "agents", planner.file) : null;
36
+ const plannerExists = plannerFile ? fs.existsSync(plannerFile) : false;
37
+
38
+ console.log("Model routing");
39
+ console.log(` ${"tier".padEnd(6)} ${"effective".padEnd(40)} source`);
40
+ for (const tier of ["plan", "build", "fast"]) {
41
+ console.log(` ${tier.padEnd(6)} ${String(models[tier] ?? "(session)").padEnd(40)} ${sources[tier] ?? "missing"}`);
42
+ }
43
+ console.log(` ${"lead".padEnd(6)} ${"session model (ci-workflow-agent)".padEnd(40)} session`);
44
+ console.log(` ${"planner".padEnd(6)} ${String(planner?.model ?? "(session)").padEnd(40)} ${planner?.mode ?? "missing"}`);
45
+ console.log(` planner file: ${plannerFile ? `${planner.file}${plannerExists ? "" : " (missing)"}` : "missing"}`);
46
+ console.log(` agents: ${Object.keys(agents).length} entries, ${warnings.length} warning(s)`);
47
+
48
+ for (const warning of warnings) console.error(`warning: ${warning}`);
49
+
50
+ if (strict && (!planner || !plannerExists || warnings.length > 0)) {
51
+ process.exit(1);
52
+ }