@plainconceptsplatform/agent-harness 3.8.0 → 3.8.2
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/cli/steps/copy/agents.js
CHANGED
|
@@ -2,7 +2,7 @@ import fse from 'fs-extra'
|
|
|
2
2
|
import path from 'path'
|
|
3
3
|
import { fileURLToPath } from 'url'
|
|
4
4
|
import { info, success, warn } from '../../utils/exec.js'
|
|
5
|
-
import { ContractError, loadContract } from '../../../harness/.opencode/scripts/platform-contract.mjs'
|
|
5
|
+
import { ContractError, isTemplateSource, loadContract } from '../../../harness/.opencode/scripts/platform-contract.mjs'
|
|
6
6
|
|
|
7
7
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
8
8
|
const agentsContent = await fse.readJson(path.resolve(__dirname, '../../presets/agents-content.json'))
|
|
@@ -74,7 +74,7 @@ function replaceBetween(content, start, end, replacement) {
|
|
|
74
74
|
return content.replace(pattern, () => (body ? `${start}\n${body}\n${end}` : `${start}\n${end}`))
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
export function reuseBlock(contract) {
|
|
77
|
+
export function reuseBlock(contract, templateSource = false) {
|
|
78
78
|
const { docs = [], packages = [], names = [] } = contract?.reuse ?? {}
|
|
79
79
|
if (!docs.length && !packages.length && !names.length) return ''
|
|
80
80
|
const code = list => list.map(item => `\`${item}\``).join(', ')
|
|
@@ -86,13 +86,14 @@ export function reuseBlock(contract) {
|
|
|
86
86
|
...(docs.length ? [`- Never design something new before reading ${code(docs)}.`] : []),
|
|
87
87
|
...(packages.length ? [`- Never re-implement an export of ${code(packages)}; import it.`] : []),
|
|
88
88
|
...(names.length ? [`- Never write your own ${code(names)}; reuse the existing one.`] : []),
|
|
89
|
-
`- Never edit a path listed under \`owned\`. Ask for the change at ${contract.source.changes}, or add a dated exception to \`${contract.exceptions}
|
|
89
|
+
...(templateSource ? [] : [`- Never edit a path listed under \`owned\`. Ask for the change at ${contract.source.changes}, or add a dated exception to \`${contract.exceptions}\`.`]),
|
|
90
90
|
].join('\n')
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
function contractBlock(cwd) {
|
|
94
94
|
try {
|
|
95
|
-
|
|
95
|
+
const contract = loadContract(cwd)
|
|
96
|
+
return reuseBlock(contract, isTemplateSource(cwd, contract))
|
|
96
97
|
} catch (err) {
|
|
97
98
|
if (!(err instanceof ContractError)) throw err
|
|
98
99
|
warn(`${err.message} The "Reuse before you build" block in AGENTS.md was left as it was.`)
|
|
@@ -18,7 +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 { ContractError, describeOwner, isOwned, loadContract } from "../scripts/platform-contract.mjs"
|
|
21
|
+
import { ContractError, describeOwner, isOwned, isTemplateSource, loadContract } from "../scripts/platform-contract.mjs"
|
|
22
22
|
|
|
23
23
|
const GUARDRAILS_SKILL = "pc-guardrails-generic"
|
|
24
24
|
const TIER_SUFFIX = /\.(?:build|fast|plan)$/
|
|
@@ -210,7 +210,7 @@ async function checkPlatform(event, root) {
|
|
|
210
210
|
return deny(event, error.message, "Fix that file before editing anything else: until it reads, nothing can tell which files the template owns.")
|
|
211
211
|
}
|
|
212
212
|
const owned = contract ? event.resources.filter(target => isOwnedTarget(target, root, contract)) : []
|
|
213
|
-
if (!owned.length || await syncInProgress(root, contract.sync.marker)) return
|
|
213
|
+
if (!owned.length || isTemplateSource(root, contract) || await syncInProgress(root, contract.sync.marker)) return
|
|
214
214
|
const { owner, fix } = describeOwner(contract)
|
|
215
215
|
return deny(event, `\`${owned[0]}\` is ${owner}.`, fix)
|
|
216
216
|
}
|
|
@@ -3,7 +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 { ContractError, describeOwner, isOwned, loadContract, nameKey, reusableNames } from './platform-contract.mjs'
|
|
6
|
+
import { ContractError, describeOwner, isOwned, isTemplateSource, loadContract, nameKey, reusableNames } from './platform-contract.mjs'
|
|
7
7
|
|
|
8
8
|
export const DEFAULT_LIMITS = {
|
|
9
9
|
commentRatio: 0.1,
|
|
@@ -213,6 +213,7 @@ export function trackedFiles(root, baseline) {
|
|
|
213
213
|
|
|
214
214
|
// A diff that changes the sync marker is a template sync, the one change allowed to rewrite owned files.
|
|
215
215
|
export function platformViolations(root, base, paths, contract) {
|
|
216
|
+
if (isTemplateSource(root, contract)) return []
|
|
216
217
|
const touched = new Set(untrackedFiles(root))
|
|
217
218
|
for (const { status, from, to } of changes(root, base)) {
|
|
218
219
|
touched.add(to)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
1
2
|
import fs from 'node:fs'
|
|
2
3
|
import path from 'node:path'
|
|
3
4
|
|
|
@@ -8,50 +9,102 @@ const LEGACY_MANIFEST = '.platform/manifest'
|
|
|
8
9
|
|
|
9
10
|
export class ContractError extends Error {}
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
let
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
i += 1
|
|
25
|
-
continue
|
|
26
|
-
}
|
|
27
|
-
if (pattern[i + 2] === '/') {
|
|
28
|
-
source += '(?:.*/)?'
|
|
29
|
-
i += 2
|
|
30
|
-
continue
|
|
31
|
-
}
|
|
12
|
+
function bracket(segment, start) {
|
|
13
|
+
const end = segment.indexOf(']', start + 2)
|
|
14
|
+
if (end === -1) return null
|
|
15
|
+
let body = segment.slice(start + 1, end)
|
|
16
|
+
const negated = body[0] === '!' || body[0] === '^'
|
|
17
|
+
if (negated) body = body.slice(1)
|
|
18
|
+
const ranges = []
|
|
19
|
+
for (let i = 0; i < body.length; i++) {
|
|
20
|
+
const from = body[i] === '\\' && i + 1 < body.length ? body[++i] : body[i]
|
|
21
|
+
let to = from
|
|
22
|
+
if (body[i + 1] === '-' && i + 2 < body.length) {
|
|
23
|
+
i += 2
|
|
24
|
+
to = body[i] === '\\' && i + 1 < body.length ? body[++i] : body[i]
|
|
32
25
|
}
|
|
26
|
+
ranges.push([from, to])
|
|
27
|
+
}
|
|
28
|
+
return { token: { negated, ranges }, end }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const STAR = Symbol('*')
|
|
32
|
+
|
|
33
|
+
function segmentTokens(segment) {
|
|
34
|
+
const tokens = []
|
|
35
|
+
for (let i = 0; i < segment.length; i++) {
|
|
36
|
+
const c = segment[i]
|
|
37
|
+
const set = c === '[' ? bracket(segment, i) : null
|
|
33
38
|
if (c === '*') {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
if (tokens.at(-1) !== STAR) tokens.push(STAR)
|
|
40
|
+
} else if (c === '?') tokens.push({ negated: true, ranges: [] })
|
|
41
|
+
else if (set) {
|
|
42
|
+
tokens.push(set.token)
|
|
43
|
+
i = set.end
|
|
44
|
+
} else tokens.push(c === '\\' && i + 1 < segment.length ? segment[++i] : c)
|
|
45
|
+
}
|
|
46
|
+
return tokens
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const tokenMatches = (token, c) => typeof token === 'string'
|
|
50
|
+
? token === c
|
|
51
|
+
: token.ranges.some(([from, to]) => from <= c && c <= to) !== token.negated
|
|
52
|
+
|
|
53
|
+
function matchSegment(tokens, text) {
|
|
54
|
+
let t = 0
|
|
55
|
+
let i = 0
|
|
56
|
+
let star = -1
|
|
57
|
+
let resume = 0
|
|
58
|
+
while (i < text.length) {
|
|
59
|
+
if (tokens[t] === STAR) {
|
|
60
|
+
star = t++
|
|
61
|
+
resume = i
|
|
62
|
+
} else if (t < tokens.length && tokenMatches(tokens[t], text[i])) {
|
|
63
|
+
t++
|
|
64
|
+
i++
|
|
65
|
+
} else if (star !== -1) {
|
|
66
|
+
t = star + 1
|
|
67
|
+
i = ++resume
|
|
68
|
+
} else return false
|
|
43
69
|
}
|
|
44
|
-
|
|
70
|
+
while (tokens[t] === STAR) t++
|
|
71
|
+
return t === tokens.length
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// gitwildmatch over `/` segments; a RegExp built from contract text is a blocking finding in the apps' Semgrep scan.
|
|
75
|
+
export function compileGlob(glob) {
|
|
76
|
+
const directory = glob.endsWith('/')
|
|
77
|
+
const body = directory ? glob.slice(0, -1) : glob
|
|
78
|
+
const segments = (body.startsWith('/') ? body.slice(1) : body).split('/')
|
|
79
|
+
return { anchored: body.includes('/'), directory, segments: segments.map(s => (s === '**' ? s : segmentTokens(s))) }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function matchFrom(segments, s, parts, p, directory) {
|
|
83
|
+
if (s === segments.length) return parts.length - p >= (directory ? 1 : 0)
|
|
84
|
+
if (segments[s] !== '**') return p < parts.length && matchSegment(segments[s], parts[p]) && matchFrom(segments, s + 1, parts, p + 1, directory)
|
|
85
|
+
for (let next = s + 1 === segments.length ? p + 1 : p; next <= parts.length; next++) {
|
|
86
|
+
if (matchFrom(segments, s + 1, parts, next, directory)) return true
|
|
87
|
+
}
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function matchGlob(compiled, file) {
|
|
92
|
+
const { anchored, directory, segments } = typeof compiled === 'string' ? compileGlob(compiled) : compiled
|
|
93
|
+
const parts = file.split('/')
|
|
94
|
+
for (let start = 0; start < (anchored ? 1 : parts.length); start++) {
|
|
95
|
+
if (matchFrom(segments, 0, parts, start, directory)) return true
|
|
96
|
+
}
|
|
97
|
+
return false
|
|
45
98
|
}
|
|
46
99
|
|
|
47
100
|
const toRules = globs => globs.map(glob => {
|
|
48
101
|
const negated = glob.startsWith('!')
|
|
49
|
-
return { negated,
|
|
102
|
+
return { negated, glob: compileGlob(negated ? glob.slice(1) : glob) }
|
|
50
103
|
})
|
|
51
104
|
|
|
52
105
|
function matchesRules(rules, file) {
|
|
53
106
|
let matched = false
|
|
54
|
-
for (const rule of rules) if (rule.
|
|
107
|
+
for (const rule of rules) if (matchGlob(rule.glob, file)) matched = !rule.negated
|
|
55
108
|
return matched
|
|
56
109
|
}
|
|
57
110
|
|
|
@@ -216,6 +269,32 @@ export function loadContract(root) {
|
|
|
216
269
|
return { ...contract, rules: toRules(contract.owned), exceptionList: Array.isArray(entries) ? entries.filter(isMapping) : [] }
|
|
217
270
|
}
|
|
218
271
|
|
|
272
|
+
export function normalizeRepoUrl(url) {
|
|
273
|
+
return String(url ?? '')
|
|
274
|
+
.trim()
|
|
275
|
+
.replace(/^git\+/, '')
|
|
276
|
+
.replace(/^gh:/, 'https://github.com/')
|
|
277
|
+
.replace(/^ssh:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\//, 'https://$1/')
|
|
278
|
+
.replace(/^git@([^:]+):/, 'https://$1/')
|
|
279
|
+
.replace(/^(https?:\/\/)[^@/]+@/, '$1')
|
|
280
|
+
.replace(/^http:\/\//, 'https://')
|
|
281
|
+
.replace(/\/+$/, '')
|
|
282
|
+
.replace(/\.git$/, '')
|
|
283
|
+
.toLowerCase()
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// The template repository carries its own contract, and its owned paths are its own to change.
|
|
287
|
+
export function isTemplateSource(root, contract) {
|
|
288
|
+
if (!contract?.source.url) return false
|
|
289
|
+
let origin
|
|
290
|
+
try {
|
|
291
|
+
origin = execFileSync('git', ['config', '--get', 'remote.origin.url'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
292
|
+
} catch {
|
|
293
|
+
return false
|
|
294
|
+
}
|
|
295
|
+
return normalizeRepoUrl(origin) !== '' && normalizeRepoUrl(origin) === normalizeRepoUrl(contract.source.url)
|
|
296
|
+
}
|
|
297
|
+
|
|
219
298
|
const localDate = (now = new Date()) =>
|
|
220
299
|
`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
|
221
300
|
|
|
@@ -223,7 +302,7 @@ export function isOwned(contract, file, today = localDate()) {
|
|
|
223
302
|
const relative = file.replace(/\\/g, '/').replace(/^\.\//, '')
|
|
224
303
|
if (!matchesRules(contract.rules, relative)) return false
|
|
225
304
|
return !contract.exceptionList.some(e =>
|
|
226
|
-
isText(e.path) && /^\d{4}-\d{2}-\d{2}$/.test(String(e.expires ?? '')) && e.expires >= today &&
|
|
305
|
+
isText(e.path) && /^\d{4}-\d{2}-\d{2}$/.test(String(e.expires ?? '')) && e.expires >= today && matchGlob(e.path, relative))
|
|
227
306
|
}
|
|
228
307
|
|
|
229
308
|
export function describeOwner(contract) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plainconceptsplatform/agent-harness",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.2",
|
|
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",
|