@plainconceptsplatform/agent-harness 3.1.1 → 3.2.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 (64) hide show
  1. package/README.md +452 -434
  2. package/cli/commands/update.js +121 -121
  3. package/cli/fragments/archive/az.md +97 -97
  4. package/cli/fragments/archive/gh.md +96 -96
  5. package/cli/fragments/archive/gl.md +96 -96
  6. package/cli/fragments/archive/none.md +75 -75
  7. package/cli/fragments/guardrails/codegraph.md +5 -5
  8. package/cli/fragments/guardrails/humanizer.md +4 -4
  9. package/cli/fragments/guardrails/memory.md +4 -4
  10. package/cli/fragments/guardrails/rtk.md +3 -3
  11. package/cli/fragments/guardrails/simple-english.md +4 -4
  12. package/cli/fragments/ops-ship/az.md +81 -81
  13. package/cli/presets/agents-content.json +34 -34
  14. package/cli/presets/browser.json +10 -10
  15. package/cli/steps/browser/index.js +48 -48
  16. package/cli/steps/copy/commands.js +79 -79
  17. package/cli/steps/copy/index.js +88 -88
  18. package/cli/steps/copy/skills.js +307 -307
  19. package/cli/steps/metadata/index.js +115 -115
  20. package/cli/steps/models/write.js +38 -38
  21. package/cli/utils/copy.js +5 -2
  22. package/cli/utils/paths.js +80 -80
  23. package/harness/.opencode/_gitignore +9 -9
  24. package/harness/.opencode/commands/init.md +5 -5
  25. package/harness/.opencode/commands/make-architecture.md +5 -5
  26. package/harness/.opencode/commands/make-design.md +5 -5
  27. package/harness/.opencode/commands/make-engineer.md +5 -5
  28. package/harness/.opencode/commands/make-guardrails.md +5 -5
  29. package/harness/.opencode/commands/make-user-model.md +5 -5
  30. package/harness/.opencode/commands/plan-apply.md +9 -9
  31. package/harness/.opencode/commands/plan-goal.md +5 -5
  32. package/harness/.opencode/commands/plan-quick.md +5 -5
  33. package/harness/.opencode/commands/plan-story.md +9 -9
  34. package/harness/.opencode/commands/repo-audit.md +5 -5
  35. package/harness/.opencode/commands/repo-initialize.md +5 -5
  36. package/harness/.opencode/commands/repo-onboard.md +5 -5
  37. package/harness/.opencode/commands/repo-verify.md +5 -5
  38. package/harness/.opencode/scripts/check-diff.mjs +225 -0
  39. package/harness/.opencode/scripts/code-rules.mjs +280 -0
  40. package/harness/AGENTS.md +49 -49
  41. package/package.json +1 -1
  42. package/skills/README.md +18 -18
  43. package/skills/browser-automation/SKILL.md +72 -72
  44. package/skills/pc-guardrails-generic/SKILL.md +2 -1
  45. package/skills/pc-make-architecture/SKILL.md +32 -32
  46. package/skills/pc-make-design/SKILL.md +45 -45
  47. package/skills/pc-make-engineer/signal-mapping.md +53 -53
  48. package/skills/pc-make-guardrails/SKILL.md +44 -44
  49. package/skills/pc-make-user-model/SKILL.md +56 -56
  50. package/skills/pc-plan-apply/SKILL.md +1 -1
  51. package/skills/pc-plan-apply/simple-mode.md +21 -21
  52. package/skills/pc-plan-archive/SKILL.md +66 -66
  53. package/skills/pc-plan-goal/branching.md +39 -39
  54. package/skills/pc-plan-goal/output.md +71 -71
  55. package/skills/pc-plan-quick/SKILL.md +46 -46
  56. package/skills/pc-plan-story/SKILL.md +93 -93
  57. package/skills/pc-repo-help/SKILL.md +87 -87
  58. package/skills/pc-repo-initialize/SKILL.md +119 -119
  59. package/skills/pc-repo-onboard/SKILL.md +32 -32
  60. package/skills/pc-repo-verify/SKILL.md +150 -150
  61. package/skills/pc-userstory-az/SKILL.md +71 -71
  62. package/skills/pc-userstory-browser/SKILL.md +64 -64
  63. package/skills/pc-userstory-gh/SKILL.md +63 -63
  64. package/skills/pc-userstory-jira/SKILL.md +74 -74
@@ -1,5 +1,5 @@
1
- ---
2
- description: Initialize the project. Presents a single form with all setup questions, then executes selected steps.
3
- ---
4
-
5
- Load the `pc-repo-initialize` skill.
1
+ ---
2
+ description: Initialize the project. Presents a single form with all setup questions, then executes selected steps.
3
+ ---
4
+
5
+ Load the `pc-repo-initialize` skill.
@@ -1,5 +1,5 @@
1
- ---
2
- description: Walk the user through the project and its agentic infrastructure. Explains what exists, how agents work, and how to use the system.
3
- ---
4
-
5
- Load the `pc-repo-onboard` skill.
1
+ ---
2
+ description: Walk the user through the project and its agentic infrastructure. Explains what exists, how agents work, and how to use the system.
3
+ ---
4
+
5
+ Load the `pc-repo-onboard` skill.
@@ -1,5 +1,5 @@
1
- ---
2
- description: Verify current-branch changes, plus immutable dependency installs, builds, and tests for every discovered project.
3
- ---
4
-
5
- Load the `pc-repo-verify` skill.
1
+ ---
2
+ description: Verify current-branch changes, plus immutable dependency installs, builds, and tests for every discovered project.
3
+ ---
4
+
5
+ Load the `pc-repo-verify` skill.
@@ -0,0 +1,225 @@
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
+ }
15
+
16
+ const FIX = {
17
+ '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.',
18
+ 'banner': 'Delete the separator.',
19
+ 'region': 'Delete the region markers.',
20
+ 'untracked-todo': 'Link an issue or delete the TODO.',
21
+ 'restating-summary': 'Delete the doc comment: it repeats the name.',
22
+ 'restating-jsdoc': 'Delete the doc comment: it repeats the name.',
23
+ 'restating-param': 'Delete the parameter doc: it repeats the name.',
24
+ 'filler-returns': 'Delete the returns doc: it says nothing the signature does not.',
25
+ 'commented-out-code': 'Delete it; git keeps the history.',
26
+ 'apologetic': 'Make the code right or delete the note.',
27
+ 'history': 'Delete the incident story; it belongs in the commit message or the pull request.',
28
+ 'essay': 'Cut the block to one line or delete it; long explanations belong in docs or the pull request.',
29
+ 'internal-doc': 'Delete the doc comment: nothing outside the file calls this.',
30
+ 'touched-file-slop': 'This file is in the diff, so leave it clean: delete the finding even though it predates the change.',
31
+ 'touched-file-density': 'This file now has more comments per code line than before. Delete comments until it does not.',
32
+ 'test-budget': 'Too much test code for the change. Keep the tests of the public contract; a bug fix gets one regression test.',
33
+ 'case-budget': 'Too many test cases for the change. Merge or delete the ones that test internals or repeat each other.',
34
+ 'test-skipped': 'Delete the skipped test or make it run.',
35
+ 'test-focused': 'Remove .only / f-prefix: it hides every other test.',
36
+ 'test-snapshot': 'Assert the values that matter instead of a snapshot.',
37
+ 'test-no-assertion': 'Assert something, or delete the test.',
38
+ 'test-mock-only': 'Assert on state or output, not only that a mock was called.',
39
+ }
40
+
41
+ const SUSPICIOUS = {
42
+ skipped: 'test-skipped',
43
+ focused: 'test-focused',
44
+ snapshot: 'test-snapshot',
45
+ noAssertion: 'test-no-assertion',
46
+ mockOnly: 'test-mock-only',
47
+ }
48
+
49
+ const EMPTY_STATS = { cases: 0, skipped: 0, focused: 0, snapshot: 0, noAssertion: 0, mockOnly: 0 }
50
+
51
+ export function evaluate(files, limits = DEFAULT_LIMITS) {
52
+ const violations = []
53
+ const add = (rule, file, line, detail) => violations.push({ rule, file, line, detail, fix: FIX[rule] })
54
+ const totals = { sourceCode: 0, testCode: 0, comments: 0, testLines: 0, cases: 0 }
55
+ const commentsByFile = new Map()
56
+
57
+ for (const file of files) {
58
+ const kind = kindOf(file.path)
59
+ if (!kind || isGenerated(file.after)) continue
60
+ const kinds = lineKinds(file.after)
61
+ for (const n of file.added) {
62
+ const k = kinds[n - 1]
63
+ if (k === 'comment') {
64
+ totals.comments += 1
65
+ commentsByFile.set(file.path, (commentsByFile.get(file.path) ?? 0) + 1)
66
+ } else if (k === 'code') {
67
+ if (kind === 'source') totals.sourceCode += 1
68
+ else totals.testCode += 1
69
+ }
70
+ }
71
+
72
+ const findings = slopFindings(file.after)
73
+ for (const f of findings) {
74
+ if (file.added.has(f.line)) add(f.rule, file.path, f.line)
75
+ else if (file.before != null) add('touched-file-slop', file.path, f.line, f.rule)
76
+ }
77
+ for (const block of commentBlocks(file.after)) {
78
+ let touched = false
79
+ for (let n = block.start; n <= block.end && !touched; n++) touched = file.added.has(n)
80
+ if (touched) for (const rule of block.rules) add(rule, file.path, block.start)
81
+ }
82
+
83
+ const before = file.before == null ? { codeLines: 0, commentLines: 0 } : countLines(file.before)
84
+ const after = countLines(file.after)
85
+ if (kind === 'source') {
86
+ const density = c => (c.codeLines > 0 ? c.commentLines / c.codeLines : c.commentLines > 0 ? Infinity : 0)
87
+ if (file.before != null && after.commentLines > before.commentLines && density(after) > density(before)) {
88
+ add('touched-file-density', file.path, undefined, `${before.commentLines}/${before.codeLines} -> ${after.commentLines}/${after.codeLines}`)
89
+ }
90
+ continue
91
+ }
92
+ totals.testLines += after.codeLines - before.codeLines
93
+ const statsBefore = file.before == null ? EMPTY_STATS : testStats(file.path, file.before)
94
+ const statsAfter = testStats(file.path, file.after)
95
+ totals.cases += Math.max(0, statsAfter.cases - statsBefore.cases)
96
+ for (const [key, rule] of Object.entries(SUSPICIOUS)) {
97
+ const grew = statsAfter[key] - statsBefore[key]
98
+ if (grew > 0) add(rule, file.path, undefined, `+${grew}`)
99
+ }
100
+ }
101
+
102
+ const commentBudget = Math.max(limits.commentAllowance, Math.floor(limits.commentRatio * (totals.sourceCode + totals.testCode)))
103
+ const testBudget = Math.max(limits.testAllowance, Math.floor(limits.testRatio * totals.sourceCode))
104
+ const caseBudget = Math.max(limits.caseAllowance, Math.floor((limits.casesPerKloc * totals.sourceCode) / 1000))
105
+ if (totals.comments > commentBudget) {
106
+ const where = [...commentsByFile].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([f, n]) => `${f} (${n})`).join(', ')
107
+ add('comment-budget', undefined, undefined, `${totals.comments} comment lines added, budget ${commentBudget}: ${where}`)
108
+ }
109
+ if (totals.testLines > testBudget) {
110
+ add('test-budget', undefined, undefined, `${totals.testLines} test lines added for ${totals.sourceCode} code lines, budget ${testBudget}`)
111
+ }
112
+ if (totals.cases > caseBudget) {
113
+ add('case-budget', undefined, undefined, `${totals.cases} test cases added for ${totals.sourceCode} code lines, budget ${caseBudget}`)
114
+ }
115
+ return { totals, budgets: { comments: commentBudget, testLines: testBudget, cases: caseBudget }, violations }
116
+ }
117
+
118
+ export function addedLines(unifiedDiff) {
119
+ const added = new Set()
120
+ for (const m of unifiedDiff.matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)) {
121
+ const start = Number(m[1])
122
+ const length = m[2] === undefined ? 1 : Number(m[2])
123
+ for (let n = start; n < start + length; n++) added.add(n)
124
+ }
125
+ return added
126
+ }
127
+
128
+ function git(root, args) {
129
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 1 << 28, stdio: ['ignore', 'pipe', 'pipe'] })
130
+ }
131
+
132
+ function tryGit(root, args) {
133
+ try {
134
+ return git(root, args).trim()
135
+ } catch {
136
+ return null
137
+ }
138
+ }
139
+
140
+ function resolveBase(root, requested) {
141
+ const candidates = requested ? [requested] : ['origin/HEAD', 'origin/main', 'origin/master', 'main', 'master']
142
+ for (const ref of candidates) {
143
+ const base = tryGit(root, ['merge-base', ref, 'HEAD'])
144
+ if (base) return { ref, base }
145
+ }
146
+ return null
147
+ }
148
+
149
+ const normalize = text => text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n')
150
+
151
+ export function collect(root, base) {
152
+ const files = []
153
+ const entries = git(root, ['diff', '--name-status', '-M', '-z', base]).split('\0').filter(Boolean)
154
+ for (let i = 0; i < entries.length; ) {
155
+ const status = entries[i]
156
+ const renamed = status.startsWith('R') || status.startsWith('C')
157
+ const from = entries[i + 1]
158
+ const to = renamed ? entries[i + 2] : from
159
+ i += renamed ? 3 : 2
160
+ if (status === 'D' || !kindOf(to)) continue
161
+ const full = path.join(root, to)
162
+ if (!fs.existsSync(full)) continue
163
+ const after = normalize(fs.readFileSync(full, 'utf8'))
164
+ const before = status === 'A' ? null : normalize(git(root, ['show', `${base}:${from}`]))
165
+ const diff = git(root, ['diff', '-U0', '--no-color', '--no-ext-diff', '-M', base, '--', ...new Set([from, to])])
166
+ files.push({ path: to, before, after, added: addedLines(diff) })
167
+ }
168
+ for (const untracked of git(root, ['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean)) {
169
+ if (!kindOf(untracked)) continue
170
+ const after = normalize(fs.readFileSync(path.join(root, untracked), 'utf8'))
171
+ const count = after.split('\n').length - (after.endsWith('\n') ? 1 : 0)
172
+ files.push({ path: untracked, before: null, after, added: new Set(Array.from({ length: count }, (_, k) => k + 1)) })
173
+ }
174
+ return files
175
+ }
176
+
177
+ function readLimits(root) {
178
+ try {
179
+ const config = JSON.parse(fs.readFileSync(path.join(root, '.opencode', 'harness.json'), 'utf8'))
180
+ return { ...DEFAULT_LIMITS, ...config.codeRules }
181
+ } catch {
182
+ return DEFAULT_LIMITS
183
+ }
184
+ }
185
+
186
+ function report(result, base) {
187
+ const { totals, budgets, violations } = result
188
+ const lines = [
189
+ `check-diff against ${base.ref} (${base.base.slice(0, 7)}): ${violations.length === 0 ? 'clean' : `${violations.length} problem${violations.length === 1 ? '' : 's'}`}`,
190
+ ` code lines added ${totals.sourceCode}, comment lines ${totals.comments}/${budgets.comments}, test lines ${totals.testLines}/${budgets.testLines}, test cases ${totals.cases}/${budgets.cases}`,
191
+ ]
192
+ const byRule = new Map()
193
+ for (const v of violations) byRule.set(v.rule, [...(byRule.get(v.rule) ?? []), v])
194
+ for (const [rule, list] of byRule) {
195
+ lines.push('', `${rule}: ${FIX[rule]}`)
196
+ for (const v of list.slice(0, 50)) {
197
+ const where = v.file ? `${v.file}${v.line ? `:${v.line}` : ''}` : ''
198
+ lines.push(` ${[where, v.detail].filter(Boolean).join(' ')}`)
199
+ }
200
+ if (list.length > 50) lines.push(` ... ${list.length - 50} more`)
201
+ }
202
+ return lines.join('\n')
203
+ }
204
+
205
+ function main(argv) {
206
+ const option = name => {
207
+ const i = argv.indexOf(`--${name}`)
208
+ return i === -1 ? undefined : argv[i + 1]
209
+ }
210
+ const root = tryGit(process.cwd(), ['rev-parse', '--show-toplevel'])
211
+ if (!root) {
212
+ console.error('check-diff: not inside a git repository.')
213
+ return 2
214
+ }
215
+ const base = resolveBase(root, option('base') ?? process.env.CHECK_DIFF_BASE)
216
+ if (!base) {
217
+ console.error('check-diff: no base to compare against. Pass --base <ref> or fetch origin.')
218
+ return 2
219
+ }
220
+ const result = evaluate(collect(root, base.base), readLimits(root))
221
+ console.log(argv.includes('--json') ? JSON.stringify({ base, ...result }, null, 2) : report(result, base))
222
+ return result.violations.length === 0 ? 0 : 1
223
+ }
224
+
225
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) process.exitCode = main(process.argv.slice(2))
@@ -0,0 +1,280 @@
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
+ export function lineKinds(text) {
22
+ const kinds = []
23
+ let inBlock = false
24
+ for (const raw of text.split('\n')) {
25
+ const t = raw.trim()
26
+ if (!t) kinds.push('blank')
27
+ else if (inBlock) {
28
+ kinds.push('comment')
29
+ if (t.includes('*/')) inBlock = false
30
+ } else if (t.startsWith('/*')) {
31
+ kinds.push('comment')
32
+ inBlock = !t.includes('*/')
33
+ } else if (t.startsWith('//')) kinds.push('comment')
34
+ else kinds.push('code')
35
+ }
36
+ return kinds
37
+ }
38
+
39
+ export function countLines(text) {
40
+ let codeLines = 0
41
+ let commentLines = 0
42
+ for (const kind of lineKinds(text)) {
43
+ if (kind === 'code') codeLines += 1
44
+ else if (kind === 'comment') commentLines += 1
45
+ }
46
+ return { codeLines, commentLines }
47
+ }
48
+
49
+ const FILLER = new Set(
50
+ '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(' '),
51
+ )
52
+ const RETURNS_FILLER = new Set([
53
+ ...FILLER,
54
+ 'result', 'results', 'task', 'representing', 'asynchronous', 'async', 'operation', 'returns', 'returned', 'which', 'that',
55
+ ])
56
+
57
+ const words = text =>
58
+ text
59
+ .replace(/<see\s+cref="([^"]+)"\s*\/>/g, ' $1 ')
60
+ .replace(/<paramref\s+name="([^"]+)"\s*\/>/g, ' $1 ')
61
+ .replace(/\{@link\s+([^}\s]+)[^}]*\}/g, ' $1 ')
62
+ .replace(/<[^>]+>/g, ' ')
63
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
64
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1 $2')
65
+ .toLowerCase()
66
+ .split(/[^a-z0-9]+/)
67
+ .filter(Boolean)
68
+
69
+ const stem = w => (w.length > 3 && w.endsWith('s') && !w.endsWith('ss') ? w.slice(0, -1) : w)
70
+
71
+ export function restates(text, identifier, filler = FILLER) {
72
+ const name = new Set(words(identifier.replace(/^I(?=[A-Z])/, '')).map(stem))
73
+ const said = words(text).filter(w => !filler.has(w) && !filler.has(stem(w))).map(stem)
74
+ return said.every(w => name.has(w))
75
+ }
76
+
77
+ const DECL_KEYWORD = /\b(?:class|record|struct|interface|enum|namespace|type|function)\s+([A-Za-z_$][\w$]*)/
78
+ const DECL_VAR = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)/
79
+ const DECL_CALL = /([A-Za-z_$][\w$]*)\s*(?:<[^()]*>)?\s*\(/
80
+ const DECL_PROP = /([A-Za-z_$][\w$]*)\s*[?!]?\s*(?:\{|=>|=|;|:|$)/
81
+
82
+ export function declaredName(line) {
83
+ const l = line.trim()
84
+ if (!l || l.startsWith('[') || l.startsWith('@')) return null
85
+ return (l.match(DECL_KEYWORD) ?? l.match(DECL_VAR) ?? l.match(DECL_CALL) ?? l.match(DECL_PROP))?.[1] ?? null
86
+ }
87
+
88
+ const BANNER = /^\/\/+\s*(?:([=\-*#~_+])\1{2,}.*|.*([=\-*#~_+])\2{2,})\s*$/
89
+ const REGION = /^#(?:region|endregion)\b/
90
+ const TODO = /\/\/.*\b(?:TODO|FIXME|HACK|XXX)\b/
91
+ const TRACKED = /#\d+|https?:\/\/|\b[A-Z][A-Z0-9]+-\d+\b|\(\s*issue\s/i
92
+ const APOLOGETIC =
93
+ /\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
94
+ const CODE_BODY =
95
+ /^(?:(?: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.*$)/
96
+ const TOOL_PRAGMA =
97
+ /^(?:nosemgrep|eslint|biome|jscpd|@ts-|prettier|istanbul|c8|v8|webpack|vite|ReSharper|resharper|language=|<auto-generated|#pragma)/
98
+
99
+ const bodyOf = trimmed => trimmed.replace(/^\/\/+\s?/, '')
100
+
101
+ export function trailingComment(line) {
102
+ let quote = null
103
+ for (let i = 0; i < line.length; i++) {
104
+ const c = line[i]
105
+ if (quote) {
106
+ if (c === '\\') i += 1
107
+ else if (c === quote) quote = null
108
+ } else if (c === "'" || c === '"' || c === '`') quote = c
109
+ else if (c === '/' && line[i + 1] === '/' && line[i - 1] !== ':') return line.slice(i)
110
+ }
111
+ return null
112
+ }
113
+
114
+ export function slopFindings(text) {
115
+ const lines = text.split('\n')
116
+ const out = []
117
+ const add = (rule, line) => out.push({ rule, line })
118
+ const nameAfter = from => {
119
+ for (let k = from; k < lines.length && k < from + 6; k++) {
120
+ const name = declaredName(lines[k])
121
+ if (name) return name
122
+ }
123
+ return null
124
+ }
125
+ let codeRun = false
126
+ for (let i = 0; i < lines.length; i++) {
127
+ const t = lines[i].trim()
128
+ const isLineComment = t.startsWith('//') && !t.startsWith('///')
129
+ const body = isLineComment ? bodyOf(t).trim() : ''
130
+ const isCode =
131
+ isLineComment && body !== '' && !TOOL_PRAGMA.test(body) && CODE_BODY.test(body) && !/\s(?:https?:)?\/\//.test(body)
132
+ if (isCode && !codeRun) add('commented-out-code', i + 1)
133
+ codeRun = isCode || (codeRun && isLineComment && !body)
134
+
135
+ if (isLineComment && BANNER.test(t)) add('banner', i + 1)
136
+ if (REGION.test(t)) add('region', i + 1)
137
+ const comment = trailingComment(t)
138
+ if (comment && TODO.test(comment) && !TRACKED.test(comment)) add('untracked-todo', i + 1)
139
+ if ((t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) && APOLOGETIC.test(t)) add('apologetic', i + 1)
140
+
141
+ if (t.startsWith('///') && t.includes('<summary>')) {
142
+ const start = i
143
+ let doc = ''
144
+ let j = i
145
+ for (; j < lines.length && lines[j].trim().startsWith('///'); j++) {
146
+ doc += ` ${lines[j].trim().replace(/^\/\/\/\s?/, '')}`
147
+ }
148
+ const name = nameAfter(j)
149
+ const summary = doc.match(/<summary>([\s\S]*?)<\/summary>/)?.[1] ?? ''
150
+ if (name && summary.trim() && restates(summary, name)) add('restating-summary', start + 1)
151
+ for (const m of doc.matchAll(/<param\s+name="([^"]+)"\s*>([\s\S]*?)<\/param>/g)) {
152
+ if (m[2].trim() && restates(m[2], m[1])) add('restating-param', start + 1)
153
+ }
154
+ for (const m of doc.matchAll(/<returns>([\s\S]*?)<\/returns>/g)) {
155
+ if (m[1].trim() && restates(m[1], '', RETURNS_FILLER)) add('filler-returns', start + 1)
156
+ }
157
+ i = j - 1
158
+ } else if (t.startsWith('/**') && !t.startsWith('/***')) {
159
+ const start = i
160
+ let j = i
161
+ let doc = ''
162
+ for (; j < lines.length; j++) {
163
+ doc += ` ${lines[j].trim()}`
164
+ if (lines[j].includes('*/')) break
165
+ }
166
+ const text = doc.replace(/^\s*\/\*\*|\*\/\s*$/g, ' ').replace(/\s\*\s?/g, ' ').trim()
167
+ const summary = text.split(/\s@\w+/)[0]?.trim() ?? ''
168
+ const name = nameAfter(j + 1)
169
+ if (name && summary && restates(summary, name)) add('restating-jsdoc', start + 1)
170
+ for (const m of text.matchAll(/@param\s+(?:\{[^}]*\}\s*)?\[?([\w$.]+)\]?\s*(?:-\s*)?([^@]*)/g)) {
171
+ if (m[2].trim() && restates(m[2], m[1])) add('restating-param', start + 1)
172
+ }
173
+ for (const m of text.matchAll(/@returns?\s+(?:\{[^}]*\}\s*)?([^@]*)/g)) {
174
+ if (m[1].trim() && restates(m[1], '', RETURNS_FILLER)) add('filler-returns', start + 1)
175
+ }
176
+ i = j
177
+ }
178
+ }
179
+ return out
180
+ }
181
+
182
+ const ISSUE_REF = /(?:\b[A-Z][\w-]*\s)?#\d+\b|\b[A-Z][A-Z0-9]+-\d+\b/
183
+ const PAST =
184
+ /\b(?:was|were|used to|previously|before|had|broke|regressed|ended up|turned out|once|anymore|no longer|caused)\b/i
185
+ const TS_INTERNAL = /^(?:async\s+)?(?:function|const|let|var|class|interface|type|enum)\b/
186
+ const CS_INTERNAL = /^(?:private|internal|private\s+protected)\b/
187
+
188
+ export function commentBlocks(text) {
189
+ const lines = text.split('\n')
190
+ const blocks = []
191
+ const firstCode = lines.findIndex(l => {
192
+ const t = l.trim()
193
+ return t !== '' && !t.startsWith('//') && !t.startsWith('/*') && !t.startsWith('*')
194
+ })
195
+ let i = 0
196
+ while (i < lines.length) {
197
+ const t = lines[i].trim()
198
+ if (!(t.startsWith('//') || t.startsWith('/*'))) {
199
+ i += 1
200
+ continue
201
+ }
202
+ const start = i
203
+ let inBlock = false
204
+ let body = ''
205
+ for (; i < lines.length; i++) {
206
+ const s = lines[i].trim()
207
+ if (inBlock) {
208
+ body += ` ${s}`
209
+ if (s.includes('*/')) inBlock = false
210
+ continue
211
+ }
212
+ if (s.startsWith('/*')) {
213
+ body += ` ${s}`
214
+ inBlock = !s.includes('*/')
215
+ continue
216
+ }
217
+ if (s.startsWith('//')) {
218
+ body += ` ${s}`
219
+ continue
220
+ }
221
+ break
222
+ }
223
+ const rules = []
224
+ if (ISSUE_REF.test(body) && PAST.test(body)) rules.push('history')
225
+ if (i - start >= 5 && start > firstCode) rules.push('essay')
226
+ if (body.trimStart().startsWith('/**') || body.trimStart().startsWith('///')) {
227
+ const next = lines.slice(i, i + 4).find(l => {
228
+ const s = l.trim()
229
+ return s !== '' && !s.startsWith('[') && !s.startsWith('@')
230
+ })
231
+ const decl = next?.trim() ?? ''
232
+ if (TS_INTERNAL.test(decl) || CS_INTERNAL.test(decl)) rules.push('internal-doc')
233
+ }
234
+ blocks.push({ start: start + 1, end: i, rules })
235
+ }
236
+ return blocks
237
+ }
238
+
239
+ const TS_CASE =
240
+ /(?:^|[^\w.$])(x|f)?(?:it|test)(\.only|\.skip|\.todo|\.concurrent|\.each\s*(?:\([^)]*\)|`[^`]*`))?\s*\(\s*['"`]/g
241
+ const CS_CASE = /\[(?:Fact|Theory|SkippableFact|SkippableTheory|Test|TestMethod)\b([^\]]*)\]/g
242
+ const TS_ASSERT = /\b(?:expect|assert)\w*(?:\.\w+)?\s*\(|\.should\b/g
243
+ const TS_CALL_ASSERT = /\.(?:not\.)?(?:toHaveBeenCalled\w*|toBeCalled\w*|toHaveBeenNthCalledWith)\s*\(/g
244
+ const CS_ASSERT = /\bAssert\w*\.\w+|\.Should\w*\(|\bVerify\w*\(|\bThrows\w*(?:Async)?\s*[<(]|\.Received\(/g
245
+ const CS_CALL_ASSERT = /\.Received\(|\.DidNotReceive\(|\.Verify\(\s*\w+\s*=>/g
246
+ const SNAPSHOT = /\.toMatch(?:Inline|File)?Snapshot\s*\(|\bawait\s+Verify(?:Json|Xml)?\s*\(/
247
+
248
+ const count = (text, re) => (text.match(re) ?? []).length
249
+
250
+ export function testStats(path, text) {
251
+ const cs = path.endsWith('.cs')
252
+ const starts = []
253
+ for (const m of text.matchAll(cs ? CS_CASE : TS_CASE)) {
254
+ if (cs) {
255
+ starts.push({ index: m.index, skipped: /\bSkip\s*=/.test(m[1] ?? ''), focused: false })
256
+ } else {
257
+ const modifier = m[2] ?? ''
258
+ if (modifier === '.todo') continue
259
+ starts.push({ index: m.index, skipped: m[1] === 'x' || modifier === '.skip', focused: m[1] === 'f' || modifier === '.only' })
260
+ }
261
+ }
262
+ const stats = { cases: starts.length, skipped: 0, focused: 0, snapshot: 0, noAssertion: 0, mockOnly: 0 }
263
+ if (cs) stats.skipped += count(text, /\[Ignore\b/g)
264
+ else {
265
+ stats.skipped += count(text, /\b(?:describe\.skip|xdescribe)\s*\(/g)
266
+ stats.focused += count(text, /\b(?:describe\.only|fdescribe)\s*\(/g)
267
+ }
268
+ for (let k = 0; k < starts.length; k++) {
269
+ const s = starts[k]
270
+ const segment = text.slice(s.index, starts[k + 1]?.index ?? text.length)
271
+ if (s.skipped) stats.skipped += 1
272
+ if (s.focused) stats.focused += 1
273
+ if (SNAPSHOT.test(segment)) stats.snapshot += 1
274
+ const asserts = count(segment, cs ? CS_ASSERT : TS_ASSERT)
275
+ const callAsserts = count(segment, cs ? CS_CALL_ASSERT : TS_CALL_ASSERT)
276
+ if (asserts === 0) stats.noAssertion += 1
277
+ else if (callAsserts > 0 && callAsserts >= asserts) stats.mockOnly += 1
278
+ }
279
+ return stats
280
+ }
package/harness/AGENTS.md CHANGED
@@ -1,49 +1,49 @@
1
- # AGENTS.md
2
-
3
- <!-- PC-NOT-INITIALIZED -->
4
-
5
- # Agent operating guide
6
-
7
- The operating contract for AI agents in this repository. Agent-agnostic: OpenCode, Claude Code, Codex, Gemini and others.
8
-
9
- ## Session context
10
-
11
- Before a non-trivial change, read `AGENTS.md` for workflow rules, `ARCHITECTURE.md` for boundaries and component interactions, `DESIGN.md` for UI and design-system work, and the active OpenSpec change or the specification covering the area you are changing.
12
-
13
- Command aliases: OpenSpec skills may reference `/opsx-propose`, `/opsx-apply`, `/opsx-archive`, or `/opsx-explore`. Always substitute them with the `pc-plan-propose`, `pc-plan-apply`, `pc-plan-archive`, and `pc-plan-explore` skills respectively. User-facing command names are `/plan-propose`, `/plan-apply`, `/plan-archive`, and `/plan-explore`. Never mention the `opsx-` names to the user.
14
-
15
- ## Workflow ownership
16
-
17
- <!-- PC-PLATFORM-WORKFLOW-START -->
18
- <!-- PC-PLATFORM-WORKFLOW-END -->
19
-
20
- ## Planning and execution
21
-
22
- - Never combine an unrelated refactor with the work you were asked to do. It arrives under a message that does not mention it, and the reviewer approves both.
23
- - Never introduce a new convention where the repository already has one, and never guess when requirements, architecture or a security constraint are unclear. Ask.
24
-
25
- ## Engineer selection
26
-
27
- Inspect `.opencode/agents/*.md` before spawning. Prefer the most specialized custom engineer. `build` and `plan` are the only primaries and are never spawned; `fullstack-engineer` is the body they share and the fallback worker, so prefer a specialist over it. If no specialist matches, tell the user to create one with `/make-engineer`. Spawn only engineers present in that directory.
28
-
29
- The `pc-plan-apply` skill is authoritative for subagent waves, dependency ordering, retries, and concurrency. A spawn past `agents.maxConcurrent` is denied (`pc-subagent-monitor`); a denied spawn is not a failed task, so re-issue it in the next wave.
30
-
31
- ## Tool and repository safety
32
-
33
- - Never expose or commit secrets, credentials, tokens, or production data.
34
- - Never overwrite a generated file, or uncommitted changes you did not make. The tree may be shared with a person and another agent.
35
- - Never bypass a check, weaken a test, or silence a lint rule to reach a green result. A green run that was arranged is worse than a red one, because nobody looks again.
36
- - Commit, push, open a pull request, change dependencies, or touch deployment configuration only with the user's explicit approval and the repository's stated process.
37
-
38
- ## Verification and completion
39
-
40
- - Never call a bug fixed without a test that would have caught it, where one is practical.
41
- - Never leave a specification, `ARCHITECTURE.md` or `DESIGN.md` asserting something this change made false.
42
- - Never end on a blocker without naming it and the decision it needs. An unattended run that stops quietly looks like one that finished.
43
-
44
- ## Skills
45
-
46
- Skills live in `.agents/skills/`. Always installed: `@pc-guardrails-generic`, `@pc-guardrails-project`, and `@browser-automation`. An entry under `## Abilities` is not a passive reference: call the `skill` tool once per `@skill-name`, guardrails first. Editing, shell and spawning stay blocked until you have (`pc-system-reminders`), and a loaded skill can require further loads.
47
-
48
- <!-- PC-PLATFORM-SKILLS-GUIDE-START -->
49
- <!-- PC-PLATFORM-SKILLS-GUIDE-END -->
1
+ # AGENTS.md
2
+
3
+ <!-- PC-NOT-INITIALIZED -->
4
+
5
+ # Agent operating guide
6
+
7
+ The operating contract for AI agents in this repository. Agent-agnostic: OpenCode, Claude Code, Codex, Gemini and others.
8
+
9
+ ## Session context
10
+
11
+ Before a non-trivial change, read `AGENTS.md` for workflow rules, `ARCHITECTURE.md` for boundaries and component interactions, `DESIGN.md` for UI and design-system work, and the active OpenSpec change or the specification covering the area you are changing.
12
+
13
+ Command aliases: OpenSpec skills may reference `/opsx-propose`, `/opsx-apply`, `/opsx-archive`, or `/opsx-explore`. Always substitute them with the `pc-plan-propose`, `pc-plan-apply`, `pc-plan-archive`, and `pc-plan-explore` skills respectively. User-facing command names are `/plan-propose`, `/plan-apply`, `/plan-archive`, and `/plan-explore`. Never mention the `opsx-` names to the user.
14
+
15
+ ## Workflow ownership
16
+
17
+ <!-- PC-PLATFORM-WORKFLOW-START -->
18
+ <!-- PC-PLATFORM-WORKFLOW-END -->
19
+
20
+ ## Planning and execution
21
+
22
+ - Never combine an unrelated refactor with the work you were asked to do. It arrives under a message that does not mention it, and the reviewer approves both.
23
+ - Never introduce a new convention where the repository already has one, and never guess when requirements, architecture or a security constraint are unclear. Ask.
24
+
25
+ ## Engineer selection
26
+
27
+ Inspect `.opencode/agents/*.md` before spawning. Prefer the most specialized custom engineer. `build` and `plan` are the only primaries and are never spawned; `fullstack-engineer` is the body they share and the fallback worker, so prefer a specialist over it. If no specialist matches, tell the user to create one with `/make-engineer`. Spawn only engineers present in that directory.
28
+
29
+ The `pc-plan-apply` skill is authoritative for subagent waves, dependency ordering, retries, and concurrency. A spawn past `agents.maxConcurrent` is denied (`pc-subagent-monitor`); a denied spawn is not a failed task, so re-issue it in the next wave.
30
+
31
+ ## Tool and repository safety
32
+
33
+ - Never expose or commit secrets, credentials, tokens, or production data.
34
+ - Never overwrite a generated file, or uncommitted changes you did not make. The tree may be shared with a person and another agent.
35
+ - Never bypass a check, weaken a test, or silence a lint rule to reach a green result. A green run that was arranged is worse than a red one, because nobody looks again.
36
+ - Commit, push, open a pull request, change dependencies, or touch deployment configuration only with the user's explicit approval and the repository's stated process.
37
+
38
+ ## Verification and completion
39
+
40
+ - Never call a bug fixed without a test that would have caught it, where one is practical.
41
+ - Never leave a specification, `ARCHITECTURE.md` or `DESIGN.md` asserting something this change made false.
42
+ - Never end on a blocker without naming it and the decision it needs. An unattended run that stops quietly looks like one that finished.
43
+
44
+ ## Skills
45
+
46
+ Skills live in `.agents/skills/`. Always installed: `@pc-guardrails-generic`, `@pc-guardrails-project`, and `@browser-automation`. An entry under `## Abilities` is not a passive reference: call the `skill` tool once per `@skill-name`, guardrails first. Editing, shell and spawning stay blocked until you have (`pc-system-reminders`), and a loaded skill can require further loads.
47
+
48
+ <!-- PC-PLATFORM-SKILLS-GUIDE-START -->
49
+ <!-- PC-PLATFORM-SKILLS-GUIDE-END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/agent-harness",
3
- "version": "3.1.1",
3
+ "version": "3.2.0",
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",