@plainconceptsplatform/agent-harness 3.1.0 → 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 (65) hide show
  1. package/README.md +452 -434
  2. package/cli/commands/update.js +1 -1
  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/index.js +0 -0
  14. package/cli/presets/agents-content.json +34 -34
  15. package/cli/presets/browser.json +10 -10
  16. package/cli/steps/browser/index.js +48 -48
  17. package/cli/steps/copy/commands.js +79 -79
  18. package/cli/steps/copy/index.js +88 -88
  19. package/cli/steps/copy/skills.js +307 -307
  20. package/cli/steps/metadata/index.js +5 -2
  21. package/cli/steps/models/write.js +5 -1
  22. package/cli/utils/copy.js +5 -2
  23. package/cli/utils/paths.js +80 -80
  24. package/harness/.opencode/_gitignore +9 -9
  25. package/harness/.opencode/commands/init.md +5 -5
  26. package/harness/.opencode/commands/make-architecture.md +5 -5
  27. package/harness/.opencode/commands/make-design.md +5 -5
  28. package/harness/.opencode/commands/make-engineer.md +5 -5
  29. package/harness/.opencode/commands/make-guardrails.md +5 -5
  30. package/harness/.opencode/commands/make-user-model.md +5 -5
  31. package/harness/.opencode/commands/plan-apply.md +9 -9
  32. package/harness/.opencode/commands/plan-goal.md +5 -5
  33. package/harness/.opencode/commands/plan-quick.md +5 -5
  34. package/harness/.opencode/commands/plan-story.md +9 -9
  35. package/harness/.opencode/commands/repo-audit.md +5 -5
  36. package/harness/.opencode/commands/repo-initialize.md +5 -5
  37. package/harness/.opencode/commands/repo-onboard.md +5 -5
  38. package/harness/.opencode/commands/repo-verify.md +5 -5
  39. package/harness/.opencode/scripts/check-diff.mjs +225 -0
  40. package/harness/.opencode/scripts/code-rules.mjs +280 -0
  41. package/harness/AGENTS.md +49 -49
  42. package/package.json +12 -14
  43. package/skills/README.md +18 -18
  44. package/skills/browser-automation/SKILL.md +72 -72
  45. package/skills/pc-guardrails-generic/SKILL.md +2 -1
  46. package/skills/pc-make-architecture/SKILL.md +32 -32
  47. package/skills/pc-make-design/SKILL.md +45 -45
  48. package/skills/pc-make-engineer/signal-mapping.md +53 -53
  49. package/skills/pc-make-guardrails/SKILL.md +44 -44
  50. package/skills/pc-make-user-model/SKILL.md +56 -56
  51. package/skills/pc-plan-apply/SKILL.md +1 -1
  52. package/skills/pc-plan-apply/simple-mode.md +21 -21
  53. package/skills/pc-plan-archive/SKILL.md +66 -66
  54. package/skills/pc-plan-goal/branching.md +39 -39
  55. package/skills/pc-plan-goal/output.md +71 -71
  56. package/skills/pc-plan-quick/SKILL.md +46 -46
  57. package/skills/pc-plan-story/SKILL.md +93 -93
  58. package/skills/pc-repo-help/SKILL.md +87 -87
  59. package/skills/pc-repo-initialize/SKILL.md +119 -119
  60. package/skills/pc-repo-onboard/SKILL.md +32 -32
  61. package/skills/pc-repo-verify/SKILL.md +150 -150
  62. package/skills/pc-userstory-az/SKILL.md +71 -71
  63. package/skills/pc-userstory-browser/SKILL.md +64 -64
  64. package/skills/pc-userstory-gh/SKILL.md +63 -63
  65. package/skills/pc-userstory-jira/SKILL.md +74 -74
@@ -13,7 +13,7 @@ import { success, warn } from '../../utils/exec.js'
13
13
  * a strict rewrite deletes user comments. jsonc-parser edits only the `model`
14
14
  * key and preserves the rest of the file byte-for-byte.
15
15
  */
16
- export async function writeModelsToConfigs({ buildModel, cwd = process.cwd() }) {
16
+ export async function writeModelsToConfigs({ buildModel, keepExisting = false, cwd = process.cwd() }) {
17
17
  const opencodeJsonPath = path.join(cwd, 'opencode.jsonc')
18
18
  if (!await fse.pathExists(opencodeJsonPath)) return
19
19
 
@@ -25,6 +25,10 @@ export async function writeModelsToConfigs({ buildModel, cwd = process.cwd() })
25
25
  return
26
26
  }
27
27
 
28
+ // A model the repository already chose is its decision, not the wizard's: `update` used to
29
+ // reset a deliberately pinned default to the build tier in every consumer it ran in.
30
+ if (keepExisting && typeof parse(text)?.model === 'string') return
31
+
28
32
  // `undefined` removes the key; a string value sets it.
29
33
  const edits = modify(text, ['model'], buildModel || undefined, {
30
34
  formattingOptions: { insertSpaces: true, tabSize: 2 },
package/cli/utils/copy.js CHANGED
@@ -33,13 +33,16 @@ async function documentedElsewhere(destDir, fileName) {
33
33
  return false
34
34
  }
35
35
 
36
+ const MANAGED_DIRS = ['.opencode/plugins/', '.opencode/scripts/']
37
+
36
38
  function isTemplateTest(relativePath) {
37
- return relativePath.split(path.sep).join('/').startsWith('.opencode/plugins/') && relativePath.endsWith('.test.js')
39
+ const normalized = relativePath.split(path.sep).join('/')
40
+ return MANAGED_DIRS.some(dir => normalized.startsWith(dir)) && normalized.endsWith('.test.js')
38
41
  }
39
42
 
40
43
  export function isManagedContentPath(relativePath) {
41
44
  const normalized = relativePath.split(path.sep).join('/')
42
- if (normalized.startsWith('.opencode/plugins/')) return true
45
+ if (MANAGED_DIRS.some(dir => normalized.startsWith(dir))) return true
43
46
  if (normalized.startsWith('.opencode/commands/') && !MARKER_COMMANDS.has(normalized)) return true
44
47
  return normalized === 'openspec/specs/.gitkeep' || normalized === 'openspec/changes/archive/.gitkeep'
45
48
  }
@@ -1,80 +1,80 @@
1
- import os from 'node:os'
2
- import path from 'node:path'
3
-
4
- /**
5
- * Every harness path that lands in a consumer repo, in one place.
6
- *
7
- * These filenames are a contract with every repo the harness has been
8
- * installed into: the CLI writes them, and the opencode plugins shipped in
9
- * harness/.opencode/plugins/ read them back at runtime. Changing a name
10
- * here means changing it in that payload too, or the plugins go blind.
11
- *
12
- * They are deliberately named for the harness rather than for the CLI, so a
13
- * future rename of the package does not become a rename of the on-disk state.
14
- */
15
- export const OPENCODE_DIR = '.opencode'
16
-
17
- /** Project config written by the metadata step. */
18
- export const CONFIG_FILE = 'harness.json'
19
-
20
- /** Per-developer model override, gitignored, never committed. */
21
- export const USER_CONFIG_FILE = 'harness.user.json'
22
-
23
- /** Hashes of every managed file, so `update` can detect local edits. */
24
- export const MANIFEST_FILE = 'harness-managed.json'
25
-
26
- /** Live subagent wave state, owned by the pc-subagent-monitor plugin. */
27
- export const RUN_STATE_FILE = 'harness-run.json'
28
-
29
- /** Names `.opencode/.gitignore` must cover. Mirrored in content/.opencode/_gitignore. */
30
- export const IGNORED_ENTRIES = [
31
- 'node_modules',
32
- RUN_STATE_FILE,
33
- USER_CONFIG_FILE,
34
- MANIFEST_FILE,
35
- 'source-roots.json',
36
- '*-engineer.*.md',
37
- // Regenerated from fullstack-engineer.md by pc-subagent-tiers every startup.
38
- 'agents/build.md',
39
- 'agents/plan.md',
40
- ]
41
-
42
- /**
43
- * Files the harness shipped and has since retired. `update` deletes them, because a skill or
44
- * command left behind keeps being offered to agents long after the procedure it describes is
45
- * gone. pc-ops-evidence wrote a capturePlan for a CI workflow that no longer exists; the
46
- * verification plan from pc-repo-verify is what the evidence workflow replays now.
47
- */
48
- export const RETIRED_PATHS = [
49
- '.agents/skills/pc-ops-evidence',
50
- '.agents/skills/pc-make-evidence-scaffold',
51
- '.opencode/commands/ops-evidence.md',
52
- '.opencode/commands/make-evidence-scaffold.md',
53
- ]
54
-
55
- export function configPath(cwd = process.cwd()) {
56
- return path.join(cwd, OPENCODE_DIR, CONFIG_FILE)
57
- }
58
-
59
- export function userConfigPath(cwd = process.cwd()) {
60
- return path.join(cwd, OPENCODE_DIR, USER_CONFIG_FILE)
61
- }
62
-
63
- export function manifestPath(cwd = process.cwd()) {
64
- return path.join(cwd, OPENCODE_DIR, MANIFEST_FILE)
65
- }
66
-
67
- /** Cross-run models cache, keyed to the CLI rather than to any one repo. */
68
- export function modelsCacheDir() {
69
- return path.join(os.homedir(), '.config', 'agent-harness')
70
- }
71
-
72
- /**
73
- * Filenames written by opencode-onboard v1.x. Only used to detect a v1 repo
74
- * and refuse to half-patch it: nothing reads their contents.
75
- */
76
- export const LEGACY_CONFIG_FILES = [
77
- 'opencode-onboard.json',
78
- 'opencode-onboard.user.json',
79
- 'opencode-onboard-managed.json',
80
- ]
1
+ import os from 'node:os'
2
+ import path from 'node:path'
3
+
4
+ /**
5
+ * Every harness path that lands in a consumer repo, in one place.
6
+ *
7
+ * These filenames are a contract with every repo the harness has been
8
+ * installed into: the CLI writes them, and the opencode plugins shipped in
9
+ * harness/.opencode/plugins/ read them back at runtime. Changing a name
10
+ * here means changing it in that payload too, or the plugins go blind.
11
+ *
12
+ * They are deliberately named for the harness rather than for the CLI, so a
13
+ * future rename of the package does not become a rename of the on-disk state.
14
+ */
15
+ export const OPENCODE_DIR = '.opencode'
16
+
17
+ /** Project config written by the metadata step. */
18
+ export const CONFIG_FILE = 'harness.json'
19
+
20
+ /** Per-developer model override, gitignored, never committed. */
21
+ export const USER_CONFIG_FILE = 'harness.user.json'
22
+
23
+ /** Hashes of every managed file, so `update` can detect local edits. */
24
+ export const MANIFEST_FILE = 'harness-managed.json'
25
+
26
+ /** Live subagent wave state, owned by the pc-subagent-monitor plugin. */
27
+ export const RUN_STATE_FILE = 'harness-run.json'
28
+
29
+ /** Names `.opencode/.gitignore` must cover. Mirrored in content/.opencode/_gitignore. */
30
+ export const IGNORED_ENTRIES = [
31
+ 'node_modules',
32
+ RUN_STATE_FILE,
33
+ USER_CONFIG_FILE,
34
+ MANIFEST_FILE,
35
+ 'source-roots.json',
36
+ '*-engineer.*.md',
37
+ // Regenerated from fullstack-engineer.md by pc-subagent-tiers every startup.
38
+ 'agents/build.md',
39
+ 'agents/plan.md',
40
+ ]
41
+
42
+ /**
43
+ * Files the harness shipped and has since retired. `update` deletes them, because a skill or
44
+ * command left behind keeps being offered to agents long after the procedure it describes is
45
+ * gone. pc-ops-evidence wrote a capturePlan for a CI workflow that no longer exists; the
46
+ * verification plan from pc-repo-verify is what the evidence workflow replays now.
47
+ */
48
+ export const RETIRED_PATHS = [
49
+ '.agents/skills/pc-ops-evidence',
50
+ '.agents/skills/pc-make-evidence-scaffold',
51
+ '.opencode/commands/ops-evidence.md',
52
+ '.opencode/commands/make-evidence-scaffold.md',
53
+ ]
54
+
55
+ export function configPath(cwd = process.cwd()) {
56
+ return path.join(cwd, OPENCODE_DIR, CONFIG_FILE)
57
+ }
58
+
59
+ export function userConfigPath(cwd = process.cwd()) {
60
+ return path.join(cwd, OPENCODE_DIR, USER_CONFIG_FILE)
61
+ }
62
+
63
+ export function manifestPath(cwd = process.cwd()) {
64
+ return path.join(cwd, OPENCODE_DIR, MANIFEST_FILE)
65
+ }
66
+
67
+ /** Cross-run models cache, keyed to the CLI rather than to any one repo. */
68
+ export function modelsCacheDir() {
69
+ return path.join(os.homedir(), '.config', 'agent-harness')
70
+ }
71
+
72
+ /**
73
+ * Filenames written by opencode-onboard v1.x. Only used to detect a v1 repo
74
+ * and refuse to half-patch it: nothing reads their contents.
75
+ */
76
+ export const LEGACY_CONFIG_FILES = [
77
+ 'opencode-onboard.json',
78
+ 'opencode-onboard.user.json',
79
+ 'opencode-onboard-managed.json',
80
+ ]
@@ -1,9 +1,9 @@
1
- node_modules
2
- harness-run.json
3
- harness.user.json
4
- harness-managed.json
5
- source-roots.json
6
- tmp/
7
- *-engineer.*.md
8
- agents/build.md
9
- agents/plan.md
1
+ node_modules
2
+ harness-run.json
3
+ harness.user.json
4
+ harness-managed.json
5
+ source-roots.json
6
+ tmp/
7
+ *-engineer.*.md
8
+ agents/build.md
9
+ agents/plan.md
@@ -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: Generate or update ARCHITECTURE.md by analyzing the codebase structure. Safe to run at any time.
3
- ---
4
-
5
- Load the `pc-make-architecture` skill.
1
+ ---
2
+ description: Generate or update ARCHITECTURE.md by analyzing the codebase structure. Safe to run at any time.
3
+ ---
4
+
5
+ Load the `pc-make-architecture` skill.
@@ -1,5 +1,5 @@
1
- ---
2
- description: Generate or update DESIGN.md by analyzing the codebase design system. Safe to run at any time.
3
- ---
4
-
5
- Load the `pc-make-design` skill.
1
+ ---
2
+ description: Generate or update DESIGN.md by analyzing the codebase design system. Safe to run at any time.
3
+ ---
4
+
5
+ Load the `pc-make-design` skill.
@@ -1,5 +1,5 @@
1
- ---
2
- description: Create a custom engineer agent via persona-driven interactive design
3
- ---
4
-
5
- Load the `pc-make-engineer` skill.
1
+ ---
2
+ description: Create a custom engineer agent via persona-driven interactive design
3
+ ---
4
+
5
+ Load the `pc-make-engineer` skill.
@@ -1,5 +1,5 @@
1
- ---
2
- description: Generate or update a pc-guardrails-project skill from ARCHITECTURE.md and relevant project files.
3
- ---
4
-
5
- Load the `pc-make-guardrails` skill.
1
+ ---
2
+ description: Generate or update a pc-guardrails-project skill from ARCHITECTURE.md and relevant project files.
3
+ ---
4
+
5
+ Load the `pc-make-guardrails` skill.
@@ -1,5 +1,5 @@
1
- ---
2
- description: Set the model for a tier (plan, build, or fast). Team-wide or user-local override.
3
- ---
4
-
5
- Load the `pc-make-user-model` skill.
1
+ ---
2
+ description: Set the model for a tier (plan, build, or fast). Team-wide or user-local override.
3
+ ---
4
+
5
+ Load the `pc-make-user-model` skill.
@@ -1,9 +1,9 @@
1
- ---
2
- description: "Implement tasks from a plan: works with OpenSpec proposals and in-conversation plans."
3
- ---
4
-
5
- Load the `pc-plan-apply` skill and execute it in **interactive mode** with `start_from: branch`.
6
-
7
- Input:
8
-
9
- $ARGUMENTS
1
+ ---
2
+ description: "Implement tasks from a plan: works with OpenSpec proposals and in-conversation plans."
3
+ ---
4
+
5
+ Load the `pc-plan-apply` skill and execute it in **interactive mode** with `start_from: branch`.
6
+
7
+ Input:
8
+
9
+ $ARGUMENTS
@@ -1,5 +1,5 @@
1
- ---
2
- description: "Autonomous pipeline: explore, propose, apply, archive, then merge/PR/push. For loop-engineering."
3
- ---
4
-
5
- Load the `pc-plan-goal` skill.
1
+ ---
2
+ description: "Autonomous pipeline: explore, propose, apply, archive, then merge/PR/push. For loop-engineering."
3
+ ---
4
+
5
+ Load the `pc-plan-goal` skill.
@@ -1,5 +1,5 @@
1
- ---
2
- description: "Quick plan: analyze the codebase and create a task checklist using the Todo pane. No files, no OpenSpec."
3
- ---
4
-
5
- Load the `pc-plan-quick` skill.
1
+ ---
2
+ description: "Quick plan: analyze the codebase and create a task checklist using the Todo pane. No files, no OpenSpec."
3
+ ---
4
+
5
+ Load the `pc-plan-quick` skill.
@@ -1,9 +1,9 @@
1
- ---
2
- description: Write a detailed, repo-aware user story from a feature idea or need.
3
- ---
4
-
5
- Load the `pc-plan-story` skill.
6
-
7
- Input:
8
-
9
- $ARGUMENTS
1
+ ---
2
+ description: Write a detailed, repo-aware user story from a feature idea or need.
3
+ ---
4
+
5
+ Load the `pc-plan-story` skill.
6
+
7
+ Input:
8
+
9
+ $ARGUMENTS
@@ -1,5 +1,5 @@
1
- ---
2
- description: Audit every configured source root against the fullstack engineer's guardrails and abilities. Read-only.
3
- ---
4
-
5
- Load the `pc-repo-audit` skill.
1
+ ---
2
+ description: Audit every configured source root against the fullstack engineer's guardrails and abilities. Read-only.
3
+ ---
4
+
5
+ Load the `pc-repo-audit` skill.
@@ -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))