@plainconceptsplatform/agent-harness 3.1.1 → 3.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.
Files changed (87) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +24 -4
  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/wizard.js +134 -134
  8. package/cli/fragments/ops-backlog/az.md +28 -28
  9. package/cli/fragments/ops-backlog/gh.md +28 -28
  10. package/cli/fragments/ops-backlog/jira.md +28 -28
  11. package/cli/fragments/ops-review/az.md +62 -62
  12. package/cli/fragments/ops-review/gh.md +51 -51
  13. package/cli/fragments/ops-review/gl.md +55 -55
  14. package/cli/fragments/ops-ship/gh.md +67 -67
  15. package/cli/fragments/ops-ship/gl.md +84 -84
  16. package/cli/index.js +116 -116
  17. package/cli/presets/clean.json +21 -21
  18. package/cli/presets/models.json +68 -68
  19. package/cli/presets/optimization.json +32 -32
  20. package/cli/presets/platforms.json +76 -76
  21. package/cli/presets/source.json +23 -23
  22. package/cli/steps/clean/index.js +120 -120
  23. package/cli/steps/copy/agents.js +120 -120
  24. package/cli/steps/copy/fullstack-engineer.js +89 -89
  25. package/cli/steps/copy/opencode-json.js +324 -324
  26. package/cli/steps/models/format.js +88 -88
  27. package/cli/steps/models/index.js +64 -64
  28. package/cli/steps/openspec/index.js +136 -136
  29. package/cli/steps/optimization/codegraph.js +141 -127
  30. package/cli/steps/optimization/detect.js +66 -66
  31. package/cli/steps/optimization/humanizer.js +17 -17
  32. package/cli/steps/optimization/index.js +158 -158
  33. package/cli/steps/optimization/memory.js +91 -88
  34. package/cli/steps/optimization/patch-guardrails.js +111 -111
  35. package/cli/steps/optimization/simple-english.js +17 -17
  36. package/cli/steps/optimization/skills-lock.js +30 -30
  37. package/cli/steps/platform/index.js +109 -109
  38. package/cli/steps/source/index.js +123 -123
  39. package/cli/utils/agent-color.js +111 -111
  40. package/cli/utils/copy.js +134 -131
  41. package/cli/utils/exec-spinner.js +47 -47
  42. package/cli/utils/exec.js +134 -134
  43. package/cli/utils/legacy-check.js +30 -30
  44. package/cli/utils/models-cache.js +58 -58
  45. package/cli/utils/models-pricing.js +42 -42
  46. package/cli/utils/process.js +3 -3
  47. package/cli/utils/terminal.js +6 -6
  48. package/cli/utils/update-manifest.js +75 -75
  49. package/harness/.opencode/commands/ops-backlog.md +10 -10
  50. package/harness/.opencode/commands/ops-review.md +8 -8
  51. package/harness/.opencode/commands/ops-ship.md +9 -9
  52. package/harness/.opencode/commands/plan-archive.md +5 -5
  53. package/harness/.opencode/commands/plan-explore.md +9 -9
  54. package/harness/.opencode/commands/plan-propose.md +9 -9
  55. package/harness/.opencode/commands/repo-help.md +5 -5
  56. package/harness/.opencode/package.json +5 -5
  57. package/harness/.opencode/plugins/pc-subagent-monitor.js +184 -184
  58. package/harness/.opencode/plugins/pc-subagent-tiers.js +354 -354
  59. package/harness/.opencode/plugins/pc-system-reminders.js +386 -386
  60. package/harness/.opencode/scripts/check-diff.mjs +244 -0
  61. package/harness/.opencode/scripts/code-rules.mjs +288 -0
  62. package/harness/ARCHITECTURE.md +13 -13
  63. package/harness/DESIGN.md +13 -13
  64. package/harness/opencode.jsonc +36 -36
  65. package/harness/openspec/config.yaml +20 -20
  66. package/harness/skills-lock.json +17 -17
  67. package/package.json +14 -12
  68. package/skills/agent-harness-cli/SKILL.md +86 -86
  69. package/skills/pc-guardrails-generic/SKILL.md +2 -1
  70. package/skills/pc-guardrails-project/SKILL.md +8 -8
  71. package/skills/pc-make-architecture/structure-template.md +64 -64
  72. package/skills/pc-make-engineer/SKILL.md +59 -59
  73. package/skills/pc-make-engineer/template.md +36 -36
  74. package/skills/pc-make-guardrails/category-reference.md +112 -112
  75. package/skills/pc-make-merge-risk-assess/SKILL.md +70 -70
  76. package/skills/pc-make-merge-risk-assess/category-reference.md +114 -114
  77. package/skills/pc-ops-ship/SKILL.md +22 -18
  78. package/skills/pc-plan-apply/SKILL.md +86 -86
  79. package/skills/pc-plan-apply/simple-mode.md +1 -1
  80. package/skills/pc-plan-explore/SKILL.md +21 -21
  81. package/skills/pc-plan-goal/SKILL.md +91 -91
  82. package/skills/pc-plan-goal/failure-policy.md +30 -30
  83. package/skills/pc-plan-goal/output-mode.md +10 -10
  84. package/skills/pc-plan-propose/SKILL.md +125 -125
  85. package/skills/pc-plan-propose/task-annotation.md +39 -39
  86. package/skills/pc-plan-quick/SKILL.md +1 -1
  87. package/skills/pc-repo-audit/SKILL.md +44 -44
@@ -0,0 +1,244 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { pathToFileURL } from 'node:url'
5
+ import { commentBlocks, countLines, isGenerated, kindOf, lineKinds, slopFindings, testStats } from './code-rules.mjs'
6
+
7
+ export const DEFAULT_LIMITS = {
8
+ commentRatio: 0.1,
9
+ commentAllowance: 2,
10
+ testRatio: 1,
11
+ testAllowance: 40,
12
+ casesPerKloc: 40,
13
+ caseAllowance: 3,
14
+ complexityBaseline: 'scripts/complexity-baseline.json',
15
+ }
16
+
17
+ const FIX = {
18
+ 'comment-budget': 'Delete comments until the diff is within budget. Keep only one that explains a constraint the code cannot show: a third-party bug, an external contract, a legal requirement.',
19
+ 'banner': 'Delete the separator.',
20
+ 'region': 'Delete the region markers.',
21
+ 'untracked-todo': 'Link an issue or delete the TODO.',
22
+ 'restating-summary': 'Delete the doc comment: it repeats the name.',
23
+ 'restating-jsdoc': 'Delete the doc comment: it repeats the name.',
24
+ 'restating-param': 'Delete the parameter doc: it repeats the name.',
25
+ 'filler-returns': 'Delete the returns doc: it says nothing the signature does not.',
26
+ 'commented-out-code': 'Delete it; git keeps the history.',
27
+ 'apologetic': 'Make the code right or delete the note.',
28
+ 'history': 'Delete the incident story; it belongs in the commit message or the pull request.',
29
+ 'essay': 'Cut the block to one line or delete it; long explanations belong in docs or the pull request.',
30
+ 'internal-doc': 'Delete the doc comment: nothing outside the file calls this.',
31
+ 'touched-file-slop': 'This file is in the diff, so leave it clean: delete the finding even though it predates the change.',
32
+ 'touched-file-density': 'This file now has more comments per code line than before. Delete comments until it does not.',
33
+ 'test-budget': 'Too much test code for the change. Keep the tests of the public contract; a bug fix gets one regression test.',
34
+ 'case-budget': 'Too many test cases for the change. Merge or delete the ones that test internals or repeat each other.',
35
+ 'test-skipped': 'Delete the skipped test or make it run.',
36
+ 'test-focused': 'Remove .only / f-prefix: it hides every other test.',
37
+ 'test-snapshot': 'Assert the values that matter instead of a snapshot.',
38
+ 'test-no-assertion': 'Assert something, or delete the test.',
39
+ 'test-mock-only': 'Assert on state or output, not only that a mock was called.',
40
+ }
41
+
42
+ const SUSPICIOUS = {
43
+ skipped: 'test-skipped',
44
+ focused: 'test-focused',
45
+ snapshot: 'test-snapshot',
46
+ noAssertion: 'test-no-assertion',
47
+ mockOnly: 'test-mock-only',
48
+ }
49
+
50
+ const EMPTY_STATS = { cases: 0, skipped: 0, focused: 0, snapshot: 0, noAssertion: 0, mockOnly: 0 }
51
+
52
+ // `tracked` holds the files a complexity gate has methods on record for. Their new lines buy twice
53
+ // the test budget: that gate fails a method whose coverage drops, so a change there needs its new
54
+ // branches tested, and trimming those tests to fit this budget is what broke Pliny-Bot #633.
55
+ export function evaluate(files, limits = DEFAULT_LIMITS, tracked = new Set()) {
56
+ const violations = []
57
+ const add = (rule, file, line, detail) => violations.push({ rule, file, line, detail, fix: FIX[rule] })
58
+ const totals = { sourceCode: 0, trackedCode: 0, testCode: 0, comments: 0, testLines: 0, cases: 0 }
59
+ const commentsByFile = new Map()
60
+
61
+ for (const file of files) {
62
+ const kind = kindOf(file.path)
63
+ if (!kind || isGenerated(file.after)) continue
64
+ const kinds = lineKinds(file.after)
65
+ for (const n of file.added) {
66
+ const k = kinds[n - 1]
67
+ if (k === 'comment') {
68
+ totals.comments += 1
69
+ commentsByFile.set(file.path, (commentsByFile.get(file.path) ?? 0) + 1)
70
+ } else if (k === 'code') {
71
+ if (kind !== 'source') totals.testCode += 1
72
+ else {
73
+ totals.sourceCode += 1
74
+ if (tracked.has(file.path)) totals.trackedCode += 1
75
+ }
76
+ }
77
+ }
78
+
79
+ const findings = slopFindings(file.after)
80
+ for (const f of findings) {
81
+ if (file.added.has(f.line)) add(f.rule, file.path, f.line)
82
+ else if (file.before != null) add('touched-file-slop', file.path, f.line, f.rule)
83
+ }
84
+ for (const block of commentBlocks(file.after)) {
85
+ let touched = false
86
+ for (let n = block.start; n <= block.end && !touched; n++) touched = file.added.has(n)
87
+ if (touched) for (const rule of block.rules) add(rule, file.path, block.start)
88
+ }
89
+
90
+ const before = file.before == null ? { codeLines: 0, commentLines: 0 } : countLines(file.before)
91
+ const after = countLines(file.after)
92
+ if (kind === 'source') {
93
+ const density = c => (c.codeLines > 0 ? c.commentLines / c.codeLines : c.commentLines > 0 ? Infinity : 0)
94
+ if (file.before != null && after.commentLines > before.commentLines && density(after) > density(before)) {
95
+ add('touched-file-density', file.path, undefined, `${before.commentLines}/${before.codeLines} -> ${after.commentLines}/${after.codeLines}`)
96
+ }
97
+ continue
98
+ }
99
+ totals.testLines += after.codeLines - before.codeLines
100
+ const statsBefore = file.before == null ? EMPTY_STATS : testStats(file.path, file.before)
101
+ const statsAfter = testStats(file.path, file.after)
102
+ totals.cases += Math.max(0, statsAfter.cases - statsBefore.cases)
103
+ for (const [key, rule] of Object.entries(SUSPICIOUS)) {
104
+ const grew = statsAfter[key] - statsBefore[key]
105
+ if (grew > 0) add(rule, file.path, undefined, `+${grew}`)
106
+ }
107
+ }
108
+
109
+ const commentBudget = Math.max(limits.commentAllowance, Math.floor(limits.commentRatio * (totals.sourceCode + totals.testCode)))
110
+ const testedCode = totals.sourceCode + totals.trackedCode
111
+ const testBudget = Math.max(limits.testAllowance, Math.floor(limits.testRatio * testedCode))
112
+ const caseBudget = Math.max(limits.caseAllowance, Math.floor((limits.casesPerKloc * testedCode) / 1000))
113
+ if (totals.comments > commentBudget) {
114
+ const where = [...commentsByFile].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([f, n]) => `${f} (${n})`).join(', ')
115
+ add('comment-budget', undefined, undefined, `${totals.comments} comment lines added, budget ${commentBudget}: ${where}`)
116
+ }
117
+ if (totals.testLines > testBudget) {
118
+ add('test-budget', undefined, undefined, `${totals.testLines} test lines added for ${totals.sourceCode} code lines, budget ${testBudget}`)
119
+ }
120
+ if (totals.cases > caseBudget) {
121
+ add('case-budget', undefined, undefined, `${totals.cases} test cases added for ${totals.sourceCode} code lines, budget ${caseBudget}`)
122
+ }
123
+ return { totals, budgets: { comments: commentBudget, testLines: testBudget, cases: caseBudget }, violations }
124
+ }
125
+
126
+ export function addedLines(unifiedDiff) {
127
+ const added = new Set()
128
+ for (const m of unifiedDiff.matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)) {
129
+ const start = Number(m[1])
130
+ const length = m[2] === undefined ? 1 : Number(m[2])
131
+ for (let n = start; n < start + length; n++) added.add(n)
132
+ }
133
+ return added
134
+ }
135
+
136
+ function git(root, args) {
137
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 1 << 28, stdio: ['ignore', 'pipe', 'pipe'] })
138
+ }
139
+
140
+ function tryGit(root, args) {
141
+ try {
142
+ return git(root, args).trim()
143
+ } catch {
144
+ return null
145
+ }
146
+ }
147
+
148
+ function resolveBase(root, requested) {
149
+ const candidates = requested ? [requested] : ['origin/HEAD', 'origin/main', 'origin/master', 'main', 'master']
150
+ for (const ref of candidates) {
151
+ const base = tryGit(root, ['merge-base', ref, 'HEAD'])
152
+ if (base) return { ref, base }
153
+ }
154
+ return null
155
+ }
156
+
157
+ const normalize = text => text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n')
158
+
159
+ export function collect(root, base) {
160
+ const files = []
161
+ const entries = git(root, ['diff', '--name-status', '-M', '-z', base]).split('\0').filter(Boolean)
162
+ for (let i = 0; i < entries.length; ) {
163
+ const status = entries[i]
164
+ const renamed = status.startsWith('R') || status.startsWith('C')
165
+ const from = entries[i + 1]
166
+ const to = renamed ? entries[i + 2] : from
167
+ i += renamed ? 3 : 2
168
+ if (status === 'D' || !kindOf(to)) continue
169
+ const full = path.join(root, to)
170
+ if (!fs.existsSync(full)) continue
171
+ const after = normalize(fs.readFileSync(full, 'utf8'))
172
+ const before = status === 'A' ? null : normalize(git(root, ['show', `${base}:${from}`]))
173
+ const diff = git(root, ['diff', '-U0', '--no-color', '--no-ext-diff', '-M', base, '--', ...new Set([from, to])])
174
+ files.push({ path: to, before, after, added: addedLines(diff) })
175
+ }
176
+ for (const untracked of git(root, ['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean)) {
177
+ if (!kindOf(untracked)) continue
178
+ const after = normalize(fs.readFileSync(path.join(root, untracked), 'utf8'))
179
+ const count = after.split('\n').length - (after.endsWith('\n') ? 1 : 0)
180
+ files.push({ path: untracked, before: null, after, added: new Set(Array.from({ length: count }, (_, k) => k + 1)) })
181
+ }
182
+ return files
183
+ }
184
+
185
+ // Paths of the files a complexity gate tracks, from its baseline keyed `path::method`.
186
+ export function trackedFiles(root, baseline) {
187
+ try {
188
+ const entries = Object.keys(JSON.parse(fs.readFileSync(path.join(root, baseline), 'utf8')))
189
+ return new Set(entries.map(key => key.split('::')[0]))
190
+ } catch {
191
+ return new Set()
192
+ }
193
+ }
194
+
195
+ function readLimits(root) {
196
+ try {
197
+ const config = JSON.parse(fs.readFileSync(path.join(root, '.opencode', 'harness.json'), 'utf8'))
198
+ return { ...DEFAULT_LIMITS, ...config.codeRules }
199
+ } catch {
200
+ return DEFAULT_LIMITS
201
+ }
202
+ }
203
+
204
+ function report(result, base) {
205
+ const { totals, budgets, violations } = result
206
+ const lines = [
207
+ `check-diff against ${base.ref} (${base.base.slice(0, 7)}): ${violations.length === 0 ? 'clean' : `${violations.length} problem${violations.length === 1 ? '' : 's'}`}`,
208
+ ` code lines added ${totals.sourceCode}, comment lines ${totals.comments}/${budgets.comments}, test lines ${totals.testLines}/${budgets.testLines}, test cases ${totals.cases}/${budgets.cases}`,
209
+ ]
210
+ const byRule = new Map()
211
+ for (const v of violations) byRule.set(v.rule, [...(byRule.get(v.rule) ?? []), v])
212
+ for (const [rule, list] of byRule) {
213
+ lines.push('', `${rule}: ${FIX[rule]}`)
214
+ for (const v of list.slice(0, 50)) {
215
+ const where = v.file ? `${v.file}${v.line ? `:${v.line}` : ''}` : ''
216
+ lines.push(` ${[where, v.detail].filter(Boolean).join(' ')}`)
217
+ }
218
+ if (list.length > 50) lines.push(` ... ${list.length - 50} more`)
219
+ }
220
+ return lines.join('\n')
221
+ }
222
+
223
+ function main(argv) {
224
+ const option = name => {
225
+ const i = argv.indexOf(`--${name}`)
226
+ return i === -1 ? undefined : argv[i + 1]
227
+ }
228
+ const root = tryGit(process.cwd(), ['rev-parse', '--show-toplevel'])
229
+ if (!root) {
230
+ console.error('check-diff: not inside a git repository.')
231
+ return 2
232
+ }
233
+ const base = resolveBase(root, option('base') ?? process.env.CHECK_DIFF_BASE)
234
+ if (!base) {
235
+ console.error('check-diff: no base to compare against. Pass --base <ref> or fetch origin.')
236
+ return 2
237
+ }
238
+ const limits = readLimits(root)
239
+ const result = evaluate(collect(root, base.base), limits, trackedFiles(root, limits.complexityBaseline))
240
+ console.log(argv.includes('--json') ? JSON.stringify({ base, ...result }, null, 2) : report(result, base))
241
+ return result.violations.length === 0 ? 0 : 1
242
+ }
243
+
244
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) process.exitCode = main(process.argv.slice(2))
@@ -0,0 +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,13 +1,13 @@
1
- > NOT GENERATED YET
2
- >
3
- > This file has not been populated yet. It is intentionally empty.
4
- >
5
- > **If this is a greenfield project** (no codebase exists yet): skip this for now. Come back and run `/make-architecture` once you have meaningful code, structure, or infrastructure in place.
6
- >
7
- > **If this is a brownfield project** (existing codebase): run this command now to generate the architecture documentation:
8
- >
9
- > ```
10
- > /make-architecture
11
- > ```
12
- >
13
- > This command analyzes your folder structure, config files, routes, data models, integrations, and build setup, then writes a complete ARCHITECTURE.md covering components, data flow, tech stack, deployment, and more. It is safe to rerun any time the architecture changes significantly.
1
+ > NOT GENERATED YET
2
+ >
3
+ > This file has not been populated yet. It is intentionally empty.
4
+ >
5
+ > **If this is a greenfield project** (no codebase exists yet): skip this for now. Come back and run `/make-architecture` once you have meaningful code, structure, or infrastructure in place.
6
+ >
7
+ > **If this is a brownfield project** (existing codebase): run this command now to generate the architecture documentation:
8
+ >
9
+ > ```
10
+ > /make-architecture
11
+ > ```
12
+ >
13
+ > This command analyzes your folder structure, config files, routes, data models, integrations, and build setup, then writes a complete ARCHITECTURE.md covering components, data flow, tech stack, deployment, and more. It is safe to rerun any time the architecture changes significantly.
package/harness/DESIGN.md CHANGED
@@ -1,13 +1,13 @@
1
- > NOT GENERATED YET
2
- >
3
- > This file has not been populated yet. It is intentionally empty.
4
- >
5
- > **If this is a greenfield project** (no UI exists yet): skip this for now. Come back and run `/make-design` once you have a design system, UI components, or styles in place.
6
- >
7
- > **If this is a brownfield project** (existing UI/styles): run this command now to generate the design documentation:
8
- >
9
- > ```
10
- > /make-design
11
- > ```
12
- >
13
- > This command analyzes your CSS, Tailwind config, component files, and design tokens, then writes a complete DESIGN.md with structured YAML tokens and written design intent. It is safe to rerun any time your design system changes.
1
+ > NOT GENERATED YET
2
+ >
3
+ > This file has not been populated yet. It is intentionally empty.
4
+ >
5
+ > **If this is a greenfield project** (no UI exists yet): skip this for now. Come back and run `/make-design` once you have a design system, UI components, or styles in place.
6
+ >
7
+ > **If this is a brownfield project** (existing UI/styles): run this command now to generate the design documentation:
8
+ >
9
+ > ```
10
+ > /make-design
11
+ > ```
12
+ >
13
+ > This command analyzes your CSS, Tailwind config, component files, and design tokens, then writes a complete DESIGN.md with structured YAML tokens and written design intent. It is safe to rerun any time your design system changes.
@@ -1,36 +1,36 @@
1
- {
2
- "$schema": "https://opencode.ai/config.json",
3
- "mcp": {
4
- "servers": {
5
- "agent-browser": {
6
- "type": "local",
7
- "command": ["agent-browser", "mcp", "--tools", "core"],
8
- "disabled": false
9
- }
10
- },
11
- "timeout": {
12
- "catalog": 300000,
13
- "execution": 300000
14
- }
15
- },
16
- "compaction": {
17
- "auto": true,
18
- "buffer": 10000
19
- },
20
- "permissions": [
21
- { "action": "question", "resource": "*", "effect": "allow" },
22
- { "action": "skill", "resource": "*", "effect": "allow" }
23
- ],
24
- "skills": [".agents/skills"],
25
- "default_agent": "plan",
26
- "agents": {
27
- "build": { "mode": "primary" },
28
- "plan": {
29
- "mode": "primary",
30
- "permissions": [
31
- { "action": "edit", "resource": "*", "effect": "deny" },
32
- { "action": "subagent", "resource": "*", "effect": "deny" }
33
- ]
34
- }
35
- }
36
- }
1
+ {
2
+ "$schema": "https://opencode.ai/config.json",
3
+ "mcp": {
4
+ "servers": {
5
+ "agent-browser": {
6
+ "type": "local",
7
+ "command": ["agent-browser", "mcp", "--tools", "core"],
8
+ "disabled": false
9
+ }
10
+ },
11
+ "timeout": {
12
+ "catalog": 300000,
13
+ "execution": 300000
14
+ }
15
+ },
16
+ "compaction": {
17
+ "auto": true,
18
+ "buffer": 10000
19
+ },
20
+ "permissions": [
21
+ { "action": "question", "resource": "*", "effect": "allow" },
22
+ { "action": "skill", "resource": "*", "effect": "allow" }
23
+ ],
24
+ "skills": [".agents/skills"],
25
+ "default_agent": "plan",
26
+ "agents": {
27
+ "build": { "mode": "primary" },
28
+ "plan": {
29
+ "mode": "primary",
30
+ "permissions": [
31
+ { "action": "edit", "resource": "*", "effect": "deny" },
32
+ { "action": "subagent", "resource": "*", "effect": "deny" }
33
+ ]
34
+ }
35
+ }
36
+ }