@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.
@@ -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
- return reuseBlock(loadContract(cwd))
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
- const escapeRegExp = text => text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
12
-
13
- export function globToRegExp(glob) {
14
- const directory = glob.endsWith('/')
15
- const body = glob.replace(/\/$/, '')
16
- const anchored = body.includes('/')
17
- const pattern = body.replace(/^\//, '')
18
- let source = ''
19
- for (let i = 0; i < pattern.length; i++) {
20
- const c = pattern[i]
21
- if (c === '*' && pattern[i + 1] === '*' && (i === 0 || pattern[i - 1] === '/')) {
22
- if (i + 2 === pattern.length) {
23
- source += '.*'
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
- source += '[^/]*'
35
- while (pattern[i + 1] === '*') i++
36
- } else if (c === '?') source += '[^/]'
37
- else if (c === '[' && pattern.indexOf(']', i + 2) !== -1) {
38
- const end = pattern.indexOf(']', i + 2)
39
- source += `[${pattern.slice(i + 1, end).replace(/^!/, '^')}]`
40
- i = end
41
- } else if (c === '\\' && i + 1 < pattern.length) source += escapeRegExp(pattern[++i])
42
- else source += escapeRegExp(c)
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
- return new RegExp(`^${anchored ? '' : '(?:.*/)?'}${source}${directory ? '/.*' : '(?:/.*)?'}$`)
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, regex: globToRegExp(negated ? glob.slice(1) : glob) }
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.regex.test(file)) matched = !rule.negated
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 && globToRegExp(e.path).test(relative))
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.0",
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",