@plainconceptsplatform/agent-harness 3.6.0 → 3.7.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.
package/README.md
CHANGED
|
@@ -295,7 +295,8 @@ your-project/
|
|
|
295
295
|
│ │ └── pc-system-reminders.js ← server plugin: gates work on the agent's abilities, denies the never-rules
|
|
296
296
|
│ └── scripts/
|
|
297
297
|
│ ├── check-diff.mjs ← the comments and tests gate on the current diff (see below)
|
|
298
|
-
│
|
|
298
|
+
│ ├── code-rules.mjs ← its rules, the same ones Project-Health scores a repository with
|
|
299
|
+
│ └── platform-manifest.mjs ← reads .platform/manifest and exceptions in a family app (see below)
|
|
299
300
|
└── .agents/
|
|
300
301
|
└── skills/
|
|
301
302
|
├── pc-guardrails-generic/ ← foundation for user guardrails
|
|
@@ -317,7 +318,8 @@ Platform skills ship as suffixed variants (`pc-userstory-gh`, `pc-userstory-az`,
|
|
|
317
318
|
- adds more comment lines than 10% of the code lines it adds (2 are always allowed);
|
|
318
319
|
- adds a slop shape (banner, region, untracked TODO, a doc comment that repeats the name, commented-out code, an apology), a comment block of five lines or more, an incident story, or a doc comment on an internal symbol;
|
|
319
320
|
- touches a file and leaves slop in it, or leaves it with more comments per code line than before;
|
|
320
|
-
- grows the tests more than the code: test lines beyond the code lines added (40 always allowed), more than 40 cases per 1,000 code lines (3 always allowed), or any new skipped, focused, snapshot, assertion-less or mock-only test
|
|
321
|
+
- grows the tests more than the code: test lines beyond the code lines added (40 always allowed), more than 40 cases per 1,000 code lines (3 always allowed), or any new skipped, focused, snapshot, assertion-less or mock-only test;
|
|
322
|
+
- changes a platform file in an app built from the Kitchen-Sink template (see [Platform files in a family app](#platform-files-in-a-family-app)).
|
|
321
323
|
|
|
322
324
|
A tool directive (`nosemgrep`, `biome-ignore`, `eslint-disable`, `@ts-expect-error`, coverage ignores) is not a comment and costs nothing. Code added to a file that has methods in the complexity baseline (`scripts/complexity-baseline.json`, keys `path::method`) counts twice toward the test budget: that gate fails a method whose coverage drops, so its new branches need tests.
|
|
323
325
|
|
|
@@ -327,6 +329,20 @@ A tool directive (`nosemgrep`, `biome-ignore`, `eslint-disable`, `@ts-expect-err
|
|
|
327
329
|
"codeRules": { "commentRatio": 0.1, "commentAllowance": 2, "testRatio": 1, "testAllowance": 40, "casesPerKloc": 40, "caseAllowance": 3, "complexityBaseline": "scripts/complexity-baseline.json" }
|
|
328
330
|
```
|
|
329
331
|
|
|
332
|
+
### Platform files in a family app
|
|
333
|
+
|
|
334
|
+
An app that receives its platform code from the Kitchen-Sink Copier template carries `.copier-answers.yml` at its root and `.platform/manifest`, one gitwildmatch glob per line for the paths the template owns. In such an app `pc-system-reminders` denies an agent edit to a manifest path, and `check-diff` reports `platform-file` for every manifest path the diff changes. Both point at the fix: change the file in Kitchen-Sink (open an issue there), or add a dated exception to the app's `.platform/exceptions.yml`:
|
|
335
|
+
|
|
336
|
+
```yaml
|
|
337
|
+
exceptions:
|
|
338
|
+
- path: apps/api/src/Platform/Audit/**
|
|
339
|
+
reason: Local audit sink until the template supports it
|
|
340
|
+
issue: https://github.com/PlainConceptsPlatform/Kitchen-Sink/issues/1
|
|
341
|
+
expires: 2026-12-31
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
An exception covers its path until the end of its `expires` day. A template sync, where `_commit` in `.copier-answers.yml` changes in the same diff, passes `check-diff`. A repository without both marker files sees no change in either tool.
|
|
345
|
+
|
|
330
346
|
## The bootstrap sequence
|
|
331
347
|
|
|
332
348
|
The first time you type `init` in OpenCode after onboarding, the agent asks whether this is a **greenfield** or **brownfield** project:
|
|
@@ -18,6 +18,7 @@ import { execFile } from "node:child_process"
|
|
|
18
18
|
import fs from "node:fs/promises"
|
|
19
19
|
import path from "node:path"
|
|
20
20
|
import { promisify } from "node:util"
|
|
21
|
+
import { isPlatformOwned, loadPlatform } from "../scripts/platform-manifest.mjs"
|
|
21
22
|
|
|
22
23
|
const GUARDRAILS_SKILL = "pc-guardrails-generic"
|
|
23
24
|
const TIER_SUFFIX = /\.(?:build|fast|plan)$/
|
|
@@ -178,6 +179,17 @@ function checkWritePath(event, target, root) {
|
|
|
178
179
|
)
|
|
179
180
|
}
|
|
180
181
|
|
|
182
|
+
function checkPlatformPath(event, target, root, platform) {
|
|
183
|
+
if (typeof target !== "string" || !target) return
|
|
184
|
+
const relative = path.isAbsolute(target) ? path.relative(root, target) : target
|
|
185
|
+
if (relative.startsWith("..") || !isPlatformOwned(platform, relative)) return
|
|
186
|
+
return deny(
|
|
187
|
+
event,
|
|
188
|
+
`\`${target}\` is platform code delivered by the Kitchen-Sink template.`,
|
|
189
|
+
"Change it in Kitchen-Sink (open an issue there), or add a dated exception to `.platform/exceptions.yml`.",
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
181
193
|
function checkHost(event, url) {
|
|
182
194
|
if (typeof url !== "string" || !url) return
|
|
183
195
|
let host = ""
|
|
@@ -352,6 +364,12 @@ export default Plugin.define({
|
|
|
352
364
|
for (const resource of event.resources) {
|
|
353
365
|
if (checkWritePath(event, resource, root)) return
|
|
354
366
|
}
|
|
367
|
+
const platform = loadPlatform(root)
|
|
368
|
+
if (platform) {
|
|
369
|
+
for (const resource of event.resources) {
|
|
370
|
+
if (checkPlatformPath(event, resource, root, platform)) return
|
|
371
|
+
}
|
|
372
|
+
}
|
|
355
373
|
}
|
|
356
374
|
|
|
357
375
|
if (event.action === "webfetch") {
|
|
@@ -3,6 +3,7 @@ import fs from 'node:fs'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { pathToFileURL } from 'node:url'
|
|
5
5
|
import { commentBlocks, countLines, isGenerated, kindOf, lineKinds, slopFindings, testStats } from './code-rules.mjs'
|
|
6
|
+
import { ANSWERS_FILE, commitOf, isPlatformOwned, loadPlatform } from './platform-manifest.mjs'
|
|
6
7
|
|
|
7
8
|
export const DEFAULT_LIMITS = {
|
|
8
9
|
commentRatio: 0.1,
|
|
@@ -37,6 +38,7 @@ const FIX = {
|
|
|
37
38
|
'test-snapshot': 'Assert the values that matter instead of a snapshot.',
|
|
38
39
|
'test-no-assertion': 'Assert something, or delete the test.',
|
|
39
40
|
'test-mock-only': 'Assert on state or output, not only that a mock was called.',
|
|
41
|
+
'platform-file': 'This is platform code delivered by the Kitchen-Sink template. Change it in Kitchen-Sink (open an issue there), or add a dated exception to .platform/exceptions.yml.',
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
const SUSPICIOUS = {
|
|
@@ -156,15 +158,23 @@ function resolveBase(root, requested) {
|
|
|
156
158
|
|
|
157
159
|
const normalize = text => text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n')
|
|
158
160
|
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
+
function changes(root, base) {
|
|
162
|
+
const list = []
|
|
161
163
|
const entries = git(root, ['diff', '--name-status', '-M', '-z', base]).split('\0').filter(Boolean)
|
|
162
164
|
for (let i = 0; i < entries.length; ) {
|
|
163
165
|
const status = entries[i]
|
|
164
166
|
const renamed = status.startsWith('R') || status.startsWith('C')
|
|
165
|
-
|
|
166
|
-
const to = renamed ? entries[i + 2] : from
|
|
167
|
+
list.push({ status, from: entries[i + 1], to: renamed ? entries[i + 2] : entries[i + 1] })
|
|
167
168
|
i += renamed ? 3 : 2
|
|
169
|
+
}
|
|
170
|
+
return list
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const untrackedFiles = root => git(root, ['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean)
|
|
174
|
+
|
|
175
|
+
export function collect(root, base) {
|
|
176
|
+
const files = []
|
|
177
|
+
for (const { status, from, to } of changes(root, base)) {
|
|
168
178
|
if (status === 'D' || !kindOf(to)) continue
|
|
169
179
|
const full = path.join(root, to)
|
|
170
180
|
if (!fs.existsSync(full)) continue
|
|
@@ -173,7 +183,7 @@ export function collect(root, base) {
|
|
|
173
183
|
const diff = git(root, ['diff', '-U0', '--no-color', '--no-ext-diff', '-M', base, '--', ...new Set([from, to])])
|
|
174
184
|
files.push({ path: to, before, after, added: addedLines(diff) })
|
|
175
185
|
}
|
|
176
|
-
for (const untracked of
|
|
186
|
+
for (const untracked of untrackedFiles(root)) {
|
|
177
187
|
if (!kindOf(untracked)) continue
|
|
178
188
|
const after = normalize(fs.readFileSync(path.join(root, untracked), 'utf8'))
|
|
179
189
|
const count = after.split('\n').length - (after.endsWith('\n') ? 1 : 0)
|
|
@@ -199,6 +209,22 @@ export function trackedFiles(root, baseline) {
|
|
|
199
209
|
}
|
|
200
210
|
}
|
|
201
211
|
|
|
212
|
+
// A template sync moves `_commit` in .copier-answers.yml and is the one change allowed to rewrite platform files.
|
|
213
|
+
export function platformViolations(root, base, paths) {
|
|
214
|
+
const platform = loadPlatform(root)
|
|
215
|
+
if (!platform) return []
|
|
216
|
+
const answers = fs.readFileSync(path.join(root, ANSWERS_FILE), 'utf8')
|
|
217
|
+
if (commitOf(tryGit(root, ['show', `${base}:${ANSWERS_FILE}`])) !== commitOf(answers)) return []
|
|
218
|
+
const touched = new Set(untrackedFiles(root))
|
|
219
|
+
for (const { status, from, to } of changes(root, base)) {
|
|
220
|
+
touched.add(to)
|
|
221
|
+
if (status.startsWith('R')) touched.add(from)
|
|
222
|
+
}
|
|
223
|
+
return scope([...touched].map(file => ({ path: file })), paths)
|
|
224
|
+
.filter(file => isPlatformOwned(platform, file.path))
|
|
225
|
+
.map(file => ({ rule: 'platform-file', file: file.path, fix: FIX['platform-file'] }))
|
|
226
|
+
}
|
|
227
|
+
|
|
202
228
|
function readLimits(root) {
|
|
203
229
|
try {
|
|
204
230
|
const config = JSON.parse(fs.readFileSync(path.join(root, '.opencode', 'harness.json'), 'utf8'))
|
|
@@ -245,6 +271,7 @@ function main(argv) {
|
|
|
245
271
|
const limits = readLimits(root)
|
|
246
272
|
const paths = option('paths')?.split(',').map(p => p.trim()).filter(Boolean)
|
|
247
273
|
const result = evaluate(scope(collect(root, base.base), paths), limits, trackedFiles(root, limits.complexityBaseline))
|
|
274
|
+
result.violations.push(...platformViolations(root, base.base, paths))
|
|
248
275
|
console.log(argv.includes('--json') ? JSON.stringify({ base, ...result }, null, 2) : report(result, base))
|
|
249
276
|
return result.violations.length === 0 ? 0 : 1
|
|
250
277
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
export const ANSWERS_FILE = '.copier-answers.yml'
|
|
5
|
+
export const MANIFEST_FILE = '.platform/manifest'
|
|
6
|
+
export const EXCEPTIONS_FILE = '.platform/exceptions.yml'
|
|
7
|
+
|
|
8
|
+
const escapeRegExp = text => text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
|
|
9
|
+
|
|
10
|
+
export function globToRegExp(glob) {
|
|
11
|
+
const directory = glob.endsWith('/')
|
|
12
|
+
const body = glob.replace(/\/$/, '')
|
|
13
|
+
const anchored = body.includes('/')
|
|
14
|
+
const pattern = body.replace(/^\//, '')
|
|
15
|
+
let source = ''
|
|
16
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
17
|
+
const c = pattern[i]
|
|
18
|
+
if (c === '*' && pattern[i + 1] === '*' && (i === 0 || pattern[i - 1] === '/')) {
|
|
19
|
+
if (i + 2 === pattern.length) {
|
|
20
|
+
source += '.*'
|
|
21
|
+
i += 1
|
|
22
|
+
continue
|
|
23
|
+
}
|
|
24
|
+
if (pattern[i + 2] === '/') {
|
|
25
|
+
source += '(?:.*/)?'
|
|
26
|
+
i += 2
|
|
27
|
+
continue
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (c === '*') {
|
|
31
|
+
source += '[^/]*'
|
|
32
|
+
while (pattern[i + 1] === '*') i++
|
|
33
|
+
} else if (c === '?') source += '[^/]'
|
|
34
|
+
else if (c === '[' && pattern.indexOf(']', i + 2) !== -1) {
|
|
35
|
+
const end = pattern.indexOf(']', i + 2)
|
|
36
|
+
source += `[${pattern.slice(i + 1, end).replace(/^!/, '^')}]`
|
|
37
|
+
i = end
|
|
38
|
+
} else if (c === '\\' && i + 1 < pattern.length) source += escapeRegExp(pattern[++i])
|
|
39
|
+
else source += escapeRegExp(c)
|
|
40
|
+
}
|
|
41
|
+
return new RegExp(`^${anchored ? '' : '(?:.*/)?'}${source}${directory ? '/.*' : '(?:/.*)?'}$`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function parseManifest(text) {
|
|
45
|
+
return text
|
|
46
|
+
.split(/\r?\n/)
|
|
47
|
+
.map(line => line.replace(/(?<!\\)\s+$/, ''))
|
|
48
|
+
.filter(line => line && !line.startsWith('#'))
|
|
49
|
+
.map(line => {
|
|
50
|
+
const negated = line.startsWith('!')
|
|
51
|
+
return { negated, regex: globToRegExp(negated ? line.slice(1) : line) }
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function matchesRules(rules, file) {
|
|
56
|
+
let matched = false
|
|
57
|
+
for (const rule of rules) if (rule.regex.test(file)) matched = !rule.negated
|
|
58
|
+
return matched
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const scalar = raw => {
|
|
62
|
+
const value = raw.trim()
|
|
63
|
+
const quoted = value.match(/^(["'])(.*)\1$/)
|
|
64
|
+
return quoted ? quoted[2] : value.replace(/\s+#.*$/, '')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function parseExceptions(text) {
|
|
68
|
+
const exceptions = []
|
|
69
|
+
let current
|
|
70
|
+
for (const line of text.split(/\r?\n/)) {
|
|
71
|
+
if (!line.trim() || line.trim().startsWith('#') || /^exceptions:/.test(line)) continue
|
|
72
|
+
const item = line.match(/^\s*-\s+(.*)$/)
|
|
73
|
+
if (item) exceptions.push((current = {}))
|
|
74
|
+
const pair = (item ? item[1] : line).match(/^\s*([A-Za-z_]+):\s*(.*)$/)
|
|
75
|
+
if (pair && current) current[pair[1]] = scalar(pair[2])
|
|
76
|
+
}
|
|
77
|
+
return exceptions
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function commitOf(answers) {
|
|
81
|
+
const match = answers?.match(/^_commit:\s*(.+)$/m)
|
|
82
|
+
return match ? scalar(match[1]) : null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const localDate = (now = new Date()) =>
|
|
86
|
+
`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
|
87
|
+
|
|
88
|
+
function readText(file) {
|
|
89
|
+
try {
|
|
90
|
+
return fs.readFileSync(file, 'utf8')
|
|
91
|
+
} catch {
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A repository opts in by carrying both files; without them every caller behaves exactly as before.
|
|
97
|
+
export function loadPlatform(root) {
|
|
98
|
+
if (!fs.existsSync(path.join(root, ANSWERS_FILE))) return null
|
|
99
|
+
const manifest = readText(path.join(root, MANIFEST_FILE))
|
|
100
|
+
if (manifest == null) return null
|
|
101
|
+
return {
|
|
102
|
+
rules: parseManifest(manifest),
|
|
103
|
+
exceptions: parseExceptions(readText(path.join(root, EXCEPTIONS_FILE)) ?? ''),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function isPlatformOwned(platform, file, today = localDate()) {
|
|
108
|
+
const relative = file.replace(/\\/g, '/').replace(/^\.\//, '')
|
|
109
|
+
if (!matchesRules(platform.rules, relative)) return false
|
|
110
|
+
return !platform.exceptions.some(e =>
|
|
111
|
+
typeof e.path === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(e.expires ?? '') && e.expires >= today && globToRegExp(e.path).test(relative))
|
|
112
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plainconceptsplatform/agent-harness",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.7.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",
|
|
@@ -12,6 +12,7 @@ Write `ARCHITECTURE.md` in the project root from what the codebase actually cont
|
|
|
12
12
|
|
|
13
13
|
- Never regenerate over a file that carries a `<!-- Last updated:` footer. That footer is what makes the next run incremental, and a full rewrite silently drops whatever a human added by hand. Read the file first and pick the mode.
|
|
14
14
|
- Never analyze outside `.opencode/source-roots.json` when it exists, plus this repo's own docs and config.
|
|
15
|
+
- When `docs/platform/` exists, the Kitchen-Sink template owns the platform code it documents (session and auth, permissions, audit, feature flags, errors, security headers, API surfaces, web shell). For those topics link to the page in `docs/platform/` instead of describing them again: a second description drifts on the next template update.
|
|
15
16
|
- The footer is the file's last line, and it is the run's own ISO timestamp: `<!-- Last updated: <ISO date> -->`.
|
|
16
17
|
- Follow the writing rules and self-check defined in the [structure template](structure-template.md). After generating, re-read each section: if a section only describes what exists without stating what breaks when its constraints are violated, rewrite it as constraint-embedded prose or cut it. The file is not written until the self-check passes.
|
|
17
18
|
|