@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
- │ └── code-rules.mjs ← its rules, the same ones Project-Health scores a repository with
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
- export function collect(root, base) {
160
- const files = []
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
- const from = entries[i + 1]
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 git(root, ['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean)) {
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.6.0",
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