@plainconceptsplatform/agent-harness 3.7.0 → 3.8.1

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
@@ -296,7 +296,7 @@ your-project/
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
+ │ └── platform-contract.mjs ← reads .platform/contract.yml, the platform contract (see below)
300
300
  └── .agents/
301
301
  └── skills/
302
302
  ├── pc-guardrails-generic/ ← foundation for user guardrails
@@ -319,7 +319,9 @@ Platform skills ship as suffixed variants (`pc-userstory-gh`, `pc-userstory-az`,
319
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;
320
320
  - touches a file and leaves slop in it, or leaves it with more comments per code line than before;
321
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)).
322
+ - changes a path the platform contract says a template owns, or finds a contract it cannot read (see [Platform contracts](#platform-contracts)).
323
+
324
+ It also warns, without failing, on a new file named like something the contract marks for reuse (`reuse-shadow`).
323
325
 
324
326
  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.
325
327
 
@@ -329,19 +331,95 @@ A tool directive (`nosemgrep`, `biome-ignore`, `eslint-disable`, `@ts-expect-err
329
331
  "codeRules": { "commentRatio": 0.1, "commentAllowance": 2, "testRatio": 1, "testAllowance": 40, "casesPerKloc": 40, "caseAllowance": 3, "complexityBaseline": "scripts/complexity-baseline.json" }
330
332
  ```
331
333
 
332
- ### Platform files in a family app
334
+ ## Platform contracts
335
+
336
+ A repository that receives shared code from an upstream template can describe that relationship in `.platform/contract.yml`. The template can be delivered by Copier, cookiecutter, a git subtree or by hand; the harness reads only the file. It uses the contract to keep agents off the shared code and to point them at what the template already provides.
337
+
338
+ The format is open. Other tools may read the same file, such as a workflow that syncs the template or a dashboard of template versions. None of them is required, and the harness never calls them.
339
+
340
+ ```yaml
341
+ version: 1
342
+ source:
343
+ name: Acme app template
344
+ url: https://github.com/acme/app-template
345
+ changes: https://github.com/acme/app-template/issues/new
346
+ owned:
347
+ - .platform/contract.yml
348
+ - src/platform/**
349
+ reuse:
350
+ docs: [docs/platform/]
351
+ packages: ["@acme/ui"]
352
+ names: [DataTable]
353
+ exceptions: .platform/exceptions.yml
354
+ sync:
355
+ marker: .copier-answers.yml
356
+ version:
357
+ file: .copier-answers.yml
358
+ key: _commit
359
+ update: copier update --trust --defaults --conflict inline
360
+ ```
361
+
362
+ The template delivers the file, so the template owns it and lists it under `owned`.
363
+
364
+ | Field | Required | What the harness does with it |
365
+ | --- | --- | --- |
366
+ | `version` | yes | Reads `1`. Any other value is an error. |
367
+ | `source.name` | yes | Names the owner in every message. |
368
+ | `source.url` | yes | Shown in the AGENTS.md block, and the default for `source.changes`. |
369
+ | `source.changes` | no | Where every message tells the agent to ask for a change. |
370
+ | `owned` | yes | gitwildmatch globs, repository-relative; a leading `/` anchors to the root and `!` excludes. `pc-system-reminders` denies agent edits to these paths, and `check-diff` reports `platform-file` for each one the diff changes. |
371
+ | `reuse.docs` | no | Listed in the AGENTS.md block as reading before building something new. `pc-make-architecture` links to these pages instead of describing their topics again. |
372
+ | `reuse.packages` | no | Listed in the AGENTS.md block. `check-diff` reads each package from `node_modules` and takes its reusable names from the `exports` subpaths and from the identifiers its type declarations export. |
373
+ | `reuse.names` | no | More reusable names, for what cannot be derived from a package. |
374
+ | `exceptions` | no | The exceptions file. Default `.platform/exceptions.yml`. |
375
+ | `sync.marker` | no | A diff that changes this file is a template sync. `check-diff` lets it rewrite owned paths, and `pc-system-reminders` allows edits to them while the file has uncommitted changes, so a sync's conflicts can be resolved. |
376
+ | `sync.version`, `sync.update` | no | Validated but not used: they are for the tool that updates the repository from the template. |
377
+
378
+ ### Reuse before you build
379
+
380
+ When the contract has `reuse`, `agent-harness` install and `update` write this block into AGENTS.md between `<!-- PC-PLATFORM-CONTRACT-START -->` and `<!-- PC-PLATFORM-CONTRACT-END -->`, appending the pair when AGENTS.md has none. For the example above:
381
+
382
+ ```markdown
383
+ ## Reuse before you build
384
+
385
+ This repository receives shared code from Acme app template (https://github.com/acme/app-template), declared in `.platform/contract.yml`.
386
+
387
+ - Never design something new before reading `docs/platform/`.
388
+ - Never re-implement an export of `@acme/ui`; import it.
389
+ - Never write your own `DataTable`; reuse the existing one.
390
+ - Never edit a path listed under `owned`. Ask for the change at https://github.com/acme/app-template/issues/new, or add a dated exception to `.platform/exceptions.yml`.
391
+ ```
392
+
393
+ Without a contract, or without `reuse`, the block is empty. With `reuse`, `pc-plan-propose` gives every proposal a `## What is reused` section, and `pc-plan-apply` stops on a proposal that lacks it. `check-diff` warns with `reuse-shadow`, without failing, when a new source file's name matches a reusable name: `data-table.tsx`, `DataTable.tsx` and `data_table.ts` all match `DataTable`.
394
+
395
+ ### Exceptions
333
396
 
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`:
397
+ An app that needs to diverge from an owned path for a while records it in the exceptions file:
335
398
 
336
399
  ```yaml
337
400
  exceptions:
338
- - path: apps/api/src/Platform/Audit/**
401
+ - path: src/platform/audit/**
339
402
  reason: Local audit sink until the template supports it
340
- issue: https://github.com/PlainConceptsPlatform/Kitchen-Sink/issues/1
403
+ issue: https://github.com/acme/app-template/issues/12
341
404
  expires: 2026-12-31
342
405
  ```
343
406
 
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.
407
+ An exception covers the owned paths its glob matches until the end of its `expires` day, in both `pc-system-reminders` and `check-diff`. An expired entry no longer applies.
408
+
409
+ ### Errors
410
+
411
+ A contract that does not read gives one error that names the file and the field, such as ``.platform/contract.yml: `source.name` is required and must be a non-empty string.`` The guards never pass silently on it: `check-diff` fails with `platform-contract`, and `pc-system-reminders` denies every edit outside `.platform/` until the file is fixed. Install and update warn and leave the AGENTS.md block as it was.
412
+
413
+ ### The legacy implicit contract
414
+
415
+ Harness 3.7.0 read `.copier-answers.yml` and `.platform/manifest`, one glob per line. A repository with both files and no `contract.yml` is still read, as this contract:
416
+
417
+ - `source.name` is the repository name at the end of `_src_path`, and `source.url` and `source.changes` are `_src_path`;
418
+ - `owned` is the manifest's lines;
419
+ - `sync.marker` and `sync.version.file` are `.copier-answers.yml`, with `sync.version.key` `_commit`;
420
+ - there is no `reuse`.
421
+
422
+ When both exist, `contract.yml` wins. A repository with neither sees no change in any tool.
345
423
 
346
424
  ## The bootstrap sequence
347
425
 
@@ -2,6 +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
6
 
6
7
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
7
8
  const agentsContent = await fse.readJson(path.resolve(__dirname, '../../presets/agents-content.json'))
@@ -46,6 +47,8 @@ const PLATFORM_WORKFLOW_START = '<!-- PC-PLATFORM-WORKFLOW-START -->'
46
47
  const PLATFORM_WORKFLOW_END = '<!-- PC-PLATFORM-WORKFLOW-END -->'
47
48
  const PLATFORM_SKILLS_GUIDE_START = '<!-- PC-PLATFORM-SKILLS-GUIDE-START -->'
48
49
  const PLATFORM_SKILLS_GUIDE_END = '<!-- PC-PLATFORM-SKILLS-GUIDE-END -->'
50
+ const PLATFORM_CONTRACT_START = '<!-- PC-PLATFORM-CONTRACT-START -->'
51
+ const PLATFORM_CONTRACT_END = '<!-- PC-PLATFORM-CONTRACT-END -->'
49
52
 
50
53
  function platformContent(platform, key) {
51
54
  const p = agentsContent.platform[platform] ?? agentsContent.platform.github
@@ -67,7 +70,42 @@ function replaceBetween(content, start, end, replacement) {
67
70
  if (!content.includes(start) || !content.includes(end)) return content
68
71
  const pattern = new RegExp(`${start}[\\s\\S]*?${end}`)
69
72
  // Replacer function so $&, $' and $` in injected content stay literal.
70
- return content.replace(pattern, () => `${start}\n${replacement.trim()}\n${end}`)
73
+ const body = replacement.trim()
74
+ return content.replace(pattern, () => (body ? `${start}\n${body}\n${end}` : `${start}\n${end}`))
75
+ }
76
+
77
+ export function reuseBlock(contract) {
78
+ const { docs = [], packages = [], names = [] } = contract?.reuse ?? {}
79
+ if (!docs.length && !packages.length && !names.length) return ''
80
+ const code = list => list.map(item => `\`${item}\``).join(', ')
81
+ return [
82
+ '## Reuse before you build',
83
+ '',
84
+ `This repository receives shared code from ${contract.source.name} (${contract.source.url}), declared in \`.platform/contract.yml\`.`,
85
+ '',
86
+ ...(docs.length ? [`- Never design something new before reading ${code(docs)}.`] : []),
87
+ ...(packages.length ? [`- Never re-implement an export of ${code(packages)}; import it.`] : []),
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}\`.`,
90
+ ].join('\n')
91
+ }
92
+
93
+ function contractBlock(cwd) {
94
+ try {
95
+ return reuseBlock(loadContract(cwd))
96
+ } catch (err) {
97
+ if (!(err instanceof ContractError)) throw err
98
+ warn(`${err.message} The "Reuse before you build" block in AGENTS.md was left as it was.`)
99
+ return null
100
+ }
101
+ }
102
+
103
+ // Installs from before the contract have no marker pair, and update never rewrites AGENTS.md: append it then.
104
+ function patchContractBlock(content, cwd) {
105
+ const block = contractBlock(cwd)
106
+ if (block == null) return content
107
+ if (content.includes(PLATFORM_CONTRACT_START)) return replaceBetween(content, PLATFORM_CONTRACT_START, PLATFORM_CONTRACT_END, block)
108
+ return block ? `${content.replace(/\s*$/, '')}\n\n${PLATFORM_CONTRACT_START}\n${block}\n${PLATFORM_CONTRACT_END}\n` : content
71
109
  }
72
110
 
73
111
  export async function patchAgentsMd(ctx) {
@@ -111,6 +149,7 @@ export async function patchAgentGuidance(backlogPlatform, repoPlatform, cwd = pr
111
149
  let content = await fse.readFile(agentsMdPath, 'utf-8')
112
150
  content = replaceBetween(content, PLATFORM_WORKFLOW_START, PLATFORM_WORKFLOW_END, mixedPlatformContent(backlogPlatform, repo, 'workflow'))
113
151
  content = replaceBetween(content, PLATFORM_SKILLS_GUIDE_START, PLATFORM_SKILLS_GUIDE_END, mixedPlatformContent(backlogPlatform, repo, 'skillsGuide'))
152
+ content = patchContractBlock(content, cwd)
114
153
  await fse.writeFile(agentsMdPath, `${content.replace(/\s*$/, '')}\n`, 'utf-8')
115
154
  const label = backlogPlatform === repo ? repo : `${backlogPlatform}→${repo}`
116
155
  success(`AGENTS.md patched for platform workflow: ${label}`)
@@ -51,6 +51,7 @@ export const RETIRED_PATHS = [
51
51
  '.agents/skills/pc-make-evidence-scaffold',
52
52
  '.opencode/commands/ops-evidence.md',
53
53
  '.opencode/commands/make-evidence-scaffold.md',
54
+ '.opencode/scripts/platform-manifest.mjs',
54
55
  ]
55
56
 
56
57
  export function configPath(cwd = process.cwd()) {
@@ -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 { isPlatformOwned, loadPlatform } from "../scripts/platform-manifest.mjs"
21
+ import { ContractError, describeOwner, isOwned, loadContract } from "../scripts/platform-contract.mjs"
22
22
 
23
23
  const GUARDRAILS_SKILL = "pc-guardrails-generic"
24
24
  const TIER_SUFFIX = /\.(?:build|fast|plan)$/
@@ -179,15 +179,40 @@ function checkWritePath(event, target, root) {
179
179
  )
180
180
  }
181
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
- )
182
+ const repoRelative = (target, root) =>
183
+ (path.isAbsolute(target) ? path.relative(root, target) : target).replace(/\\/g, "/").replace(/^\.\//, "")
184
+
185
+ function isOwnedTarget(target, root, contract) {
186
+ if (typeof target !== "string" || !target) return false
187
+ const relative = repoRelative(target, root)
188
+ return !relative.startsWith("..") && isOwned(contract, relative)
189
+ }
190
+
191
+ // A template sync in progress has changed its marker file, and resolving its conflicts edits owned files.
192
+ async function syncInProgress(root, marker) {
193
+ if (!marker) return false
194
+ try {
195
+ const { stdout } = await run("git", ["status", "--porcelain", "--", marker], { cwd: root })
196
+ return stdout.trim() !== ""
197
+ } catch {
198
+ return false
199
+ }
200
+ }
201
+
202
+ async function checkPlatform(event, root) {
203
+ let contract
204
+ try {
205
+ contract = loadContract(root)
206
+ } catch (error) {
207
+ if (!(error instanceof ContractError)) throw error
208
+ // An unreadable contract must not pass every edit: only the files that fix it stay editable.
209
+ if (event.resources.every(target => repoRelative(String(target), root).startsWith(".platform/"))) return
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
+ }
212
+ const owned = contract ? event.resources.filter(target => isOwnedTarget(target, root, contract)) : []
213
+ if (!owned.length || await syncInProgress(root, contract.sync.marker)) return
214
+ const { owner, fix } = describeOwner(contract)
215
+ return deny(event, `\`${owned[0]}\` is ${owner}.`, fix)
191
216
  }
192
217
 
193
218
  function checkHost(event, url) {
@@ -364,12 +389,7 @@ export default Plugin.define({
364
389
  for (const resource of event.resources) {
365
390
  if (checkWritePath(event, resource, root)) return
366
391
  }
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
- }
392
+ if (event.effect !== "deny") await checkPlatform(event, root)
373
393
  }
374
394
 
375
395
  if (event.action === "webfetch") {
@@ -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 { ANSWERS_FILE, commitOf, isPlatformOwned, loadPlatform } from './platform-manifest.mjs'
6
+ import { ContractError, describeOwner, isOwned, loadContract, nameKey, reusableNames } from './platform-contract.mjs'
7
7
 
8
8
  export const DEFAULT_LIMITS = {
9
9
  commentRatio: 0.1,
@@ -38,7 +38,9 @@ const FIX = {
38
38
  'test-snapshot': 'Assert the values that matter instead of a snapshot.',
39
39
  'test-no-assertion': 'Assert something, or delete the test.',
40
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.',
41
+ 'platform-file': 'This file is owned by the template named in the platform contract. Ask for the change there, or add a dated exception.',
42
+ 'platform-contract': 'Fix the contract: until it reads, no platform file can be checked.',
43
+ 'reuse-shadow': 'The platform contract marks this name for reuse. Use the existing one, or rename the file if it is something else.',
42
44
  }
43
45
 
44
46
  const SUSPICIOUS = {
@@ -209,20 +211,41 @@ export function trackedFiles(root, baseline) {
209
211
  }
210
212
  }
211
213
 
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 []
214
+ // A diff that changes the sync marker is a template sync, the one change allowed to rewrite owned files.
215
+ export function platformViolations(root, base, paths, contract) {
218
216
  const touched = new Set(untrackedFiles(root))
219
217
  for (const { status, from, to } of changes(root, base)) {
220
218
  touched.add(to)
221
219
  if (status.startsWith('R')) touched.add(from)
222
220
  }
221
+ if (contract.sync.marker && touched.has(contract.sync.marker)) return []
222
+ const { owner, fix } = describeOwner(contract)
223
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'] }))
224
+ .filter(file => isOwned(contract, file.path))
225
+ .map(file => ({ rule: 'platform-file', file: file.path, detail: owner, fix }))
226
+ }
227
+
228
+ export function reuseWarnings(root, base, paths, contract) {
229
+ if (!contract.reuse.packages.length && !contract.reuse.names.length) return []
230
+ const added = [...untrackedFiles(root), ...changes(root, base).filter(c => c.status === 'A').map(c => c.to)]
231
+ const files = scope(added.map(file => ({ path: file })), paths).filter(f => kindOf(f.path) === 'source' && !isOwned(contract, f.path))
232
+ if (!files.length) return []
233
+ const names = reusableNames(root, contract, files.map(f => f.path))
234
+ return files.flatMap(file => {
235
+ const match = names.get(nameKey(path.basename(file.path).replace(/\.[^.]+$/, '')))
236
+ return match ? [{ rule: 'reuse-shadow', file: file.path, detail: `${match.name} from ${match.origin}`, fix: FIX['reuse-shadow'] }] : []
237
+ })
238
+ }
239
+
240
+ function platformFindings(root, base, paths) {
241
+ try {
242
+ const contract = loadContract(root)
243
+ if (!contract) return { violations: [], warnings: [] }
244
+ return { violations: platformViolations(root, base, paths, contract), warnings: reuseWarnings(root, base, paths, contract) }
245
+ } catch (error) {
246
+ if (!(error instanceof ContractError)) throw error
247
+ return { violations: [{ rule: 'platform-contract', detail: error.message, fix: FIX['platform-contract'] }], warnings: [] }
248
+ }
226
249
  }
227
250
 
228
251
  function readLimits(root) {
@@ -241,9 +264,9 @@ function report(result, base) {
241
264
  ` code lines added ${totals.sourceCode}, comment lines ${totals.comments}/${budgets.comments}, test lines ${totals.testLines}/${budgets.testLines}, test cases ${totals.cases}/${budgets.cases}`,
242
265
  ]
243
266
  const byRule = new Map()
244
- for (const v of violations) byRule.set(v.rule, [...(byRule.get(v.rule) ?? []), v])
267
+ for (const v of [...violations, ...(result.warnings ?? [])]) byRule.set(v.rule, [...(byRule.get(v.rule) ?? []), v])
245
268
  for (const [rule, list] of byRule) {
246
- lines.push('', `${rule}: ${FIX[rule]}`)
269
+ lines.push('', `${rule}${violations.includes(list[0]) ? '' : ' (warning)'}: ${list[0].fix ?? FIX[rule]}`)
247
270
  for (const v of list.slice(0, 50)) {
248
271
  const where = v.file ? `${v.file}${v.line ? `:${v.line}` : ''}` : ''
249
272
  lines.push(` ${[where, v.detail].filter(Boolean).join(' ')}`)
@@ -271,7 +294,9 @@ function main(argv) {
271
294
  const limits = readLimits(root)
272
295
  const paths = option('paths')?.split(',').map(p => p.trim()).filter(Boolean)
273
296
  const result = evaluate(scope(collect(root, base.base), paths), limits, trackedFiles(root, limits.complexityBaseline))
274
- result.violations.push(...platformViolations(root, base.base, paths))
297
+ const platform = platformFindings(root, base.base, paths)
298
+ result.violations.push(...platform.violations)
299
+ if (platform.warnings.length) result.warnings = platform.warnings
275
300
  console.log(argv.includes('--json') ? JSON.stringify({ base, ...result }, null, 2) : report(result, base))
276
301
  return result.violations.length === 0 ? 0 : 1
277
302
  }
@@ -0,0 +1,339 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ export const CONTRACT_FILE = '.platform/contract.yml'
5
+ const DEFAULT_EXCEPTIONS = '.platform/exceptions.yml'
6
+ const LEGACY_ANSWERS = '.copier-answers.yml'
7
+ const LEGACY_MANIFEST = '.platform/manifest'
8
+
9
+ export class ContractError extends Error {}
10
+
11
+ function bracket(segment, start) {
12
+ const end = segment.indexOf(']', start + 2)
13
+ if (end === -1) return null
14
+ let body = segment.slice(start + 1, end)
15
+ const negated = body[0] === '!' || body[0] === '^'
16
+ if (negated) body = body.slice(1)
17
+ const ranges = []
18
+ for (let i = 0; i < body.length; i++) {
19
+ const from = body[i] === '\\' && i + 1 < body.length ? body[++i] : body[i]
20
+ let to = from
21
+ if (body[i + 1] === '-' && i + 2 < body.length) {
22
+ i += 2
23
+ to = body[i] === '\\' && i + 1 < body.length ? body[++i] : body[i]
24
+ }
25
+ ranges.push([from, to])
26
+ }
27
+ return { token: { negated, ranges }, end }
28
+ }
29
+
30
+ const STAR = Symbol('*')
31
+
32
+ function segmentTokens(segment) {
33
+ const tokens = []
34
+ for (let i = 0; i < segment.length; i++) {
35
+ const c = segment[i]
36
+ const set = c === '[' ? bracket(segment, i) : null
37
+ if (c === '*') {
38
+ if (tokens.at(-1) !== STAR) tokens.push(STAR)
39
+ } else if (c === '?') tokens.push({ negated: true, ranges: [] })
40
+ else if (set) {
41
+ tokens.push(set.token)
42
+ i = set.end
43
+ } else tokens.push(c === '\\' && i + 1 < segment.length ? segment[++i] : c)
44
+ }
45
+ return tokens
46
+ }
47
+
48
+ const tokenMatches = (token, c) => typeof token === 'string'
49
+ ? token === c
50
+ : token.ranges.some(([from, to]) => from <= c && c <= to) !== token.negated
51
+
52
+ function matchSegment(tokens, text) {
53
+ let t = 0
54
+ let i = 0
55
+ let star = -1
56
+ let resume = 0
57
+ while (i < text.length) {
58
+ if (tokens[t] === STAR) {
59
+ star = t++
60
+ resume = i
61
+ } else if (t < tokens.length && tokenMatches(tokens[t], text[i])) {
62
+ t++
63
+ i++
64
+ } else if (star !== -1) {
65
+ t = star + 1
66
+ i = ++resume
67
+ } else return false
68
+ }
69
+ while (tokens[t] === STAR) t++
70
+ return t === tokens.length
71
+ }
72
+
73
+ // gitwildmatch over `/` segments; a RegExp built from contract text is a blocking finding in the apps' Semgrep scan.
74
+ export function compileGlob(glob) {
75
+ const directory = glob.endsWith('/')
76
+ const body = directory ? glob.slice(0, -1) : glob
77
+ const segments = (body.startsWith('/') ? body.slice(1) : body).split('/')
78
+ return { anchored: body.includes('/'), directory, segments: segments.map(s => (s === '**' ? s : segmentTokens(s))) }
79
+ }
80
+
81
+ function matchFrom(segments, s, parts, p, directory) {
82
+ if (s === segments.length) return parts.length - p >= (directory ? 1 : 0)
83
+ if (segments[s] !== '**') return p < parts.length && matchSegment(segments[s], parts[p]) && matchFrom(segments, s + 1, parts, p + 1, directory)
84
+ for (let next = s + 1 === segments.length ? p + 1 : p; next <= parts.length; next++) {
85
+ if (matchFrom(segments, s + 1, parts, next, directory)) return true
86
+ }
87
+ return false
88
+ }
89
+
90
+ export function matchGlob(compiled, file) {
91
+ const { anchored, directory, segments } = typeof compiled === 'string' ? compileGlob(compiled) : compiled
92
+ const parts = file.split('/')
93
+ for (let start = 0; start < (anchored ? 1 : parts.length); start++) {
94
+ if (matchFrom(segments, 0, parts, start, directory)) return true
95
+ }
96
+ return false
97
+ }
98
+
99
+ const toRules = globs => globs.map(glob => {
100
+ const negated = glob.startsWith('!')
101
+ return { negated, glob: compileGlob(negated ? glob.slice(1) : glob) }
102
+ })
103
+
104
+ function matchesRules(rules, file) {
105
+ let matched = false
106
+ for (const rule of rules) if (matchGlob(rule.glob, file)) matched = !rule.negated
107
+ return matched
108
+ }
109
+
110
+ function stripComment(line) {
111
+ let quote = null
112
+ for (let i = 0; i < line.length; i++) {
113
+ const c = line[i]
114
+ if (quote) quote = c === quote ? null : quote
115
+ else if ((c === '"' || c === "'") && (i === 0 || /[\s[,]/.test(line[i - 1]))) quote = c
116
+ else if (c === '#' && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i)
117
+ }
118
+ return line
119
+ }
120
+
121
+ function scalar(raw) {
122
+ const value = raw.trim()
123
+ const quoted = value.match(/^(["'])(.*)\1$/)
124
+ if (quoted) return quoted[2]
125
+ if (value.startsWith('[') && value.endsWith(']')) return value.slice(1, -1).split(',').map(scalar).filter(item => item !== '')
126
+ return /^-?\d+$/.test(value) ? Number(value) : value
127
+ }
128
+
129
+ const isItem = text => text === '-' || text.startsWith('- ')
130
+
131
+ // The subset of YAML a contract and its exceptions use: nested mappings, block and flow lists, scalars.
132
+ export function parseYaml(text, file) {
133
+ const lines = []
134
+ text.replace(/^\uFEFF/, '').split(/\r?\n/).forEach((raw, index) => {
135
+ const body = stripComment(raw).trimEnd()
136
+ if (body.trim()) lines.push({ indent: body.length - body.trimStart().length, text: body.trim(), number: index + 1 })
137
+ })
138
+ const fail = line => {
139
+ throw new ContractError(`${file}: line ${line.number} cannot be read: \`${line.text}\`.`)
140
+ }
141
+
142
+ function block(start, indent) {
143
+ const list = isItem(lines[start].text)
144
+ const value = list ? [] : {}
145
+ let i = start
146
+ while (i < lines.length && lines[i].indent === indent) {
147
+ const line = lines[i]
148
+ if (isItem(line.text) !== list) {
149
+ if (list) break
150
+ fail(line)
151
+ }
152
+ if (list) {
153
+ const rest = line.text.slice(1).trimStart()
154
+ if (/^[^"'[{][^:]*:(\s|$)/.test(rest)) {
155
+ lines[i] = { ...line, indent: indent + line.text.length - rest.length, text: rest }
156
+ } else {
157
+ i++
158
+ if (rest || !(i < lines.length && lines[i].indent > indent)) {
159
+ value.push(rest ? scalar(rest) : null)
160
+ continue
161
+ }
162
+ }
163
+ const [item, after] = block(i, lines[i].indent)
164
+ value.push(item)
165
+ i = after
166
+ continue
167
+ }
168
+ const pair = line.text.match(/^([^:]+?)\s*:(?:\s+(.*))?$/)
169
+ if (!pair) fail(line)
170
+ const key = scalar(pair[1])
171
+ i++
172
+ const next = lines[i]
173
+ if (pair[2]) value[key] = scalar(pair[2])
174
+ else if (next && (next.indent > indent || (next.indent === indent && isItem(next.text)))) [value[key], i] = block(i, next.indent)
175
+ else value[key] = null
176
+ }
177
+ if (i < lines.length && lines[i].indent > indent) fail(lines[i])
178
+ return [value, i]
179
+ }
180
+
181
+ if (!lines.length) return null
182
+ const [value, end] = block(0, lines[0].indent)
183
+ if (end < lines.length) fail(lines[end])
184
+ return value
185
+ }
186
+
187
+ const isText = value => typeof value === 'string' && value.trim() !== ''
188
+ const isMapping = value => value !== null && typeof value === 'object' && !Array.isArray(value)
189
+ const invalid = (file, field, problem) => new ContractError(`${file}: \`${field}\` ${problem}`)
190
+
191
+ function mapping(value, file, field, required = false) {
192
+ if (value == null && !required) return {}
193
+ if (!isMapping(value)) throw invalid(file, field, required ? 'is required and must be a mapping.' : 'must be a mapping.')
194
+ return value
195
+ }
196
+
197
+ function text(value, file, field, fallback) {
198
+ if (value == null && fallback !== undefined) return fallback
199
+ if (!isText(value)) throw invalid(file, field, fallback === undefined ? 'is required and must be a non-empty string.' : 'must be a non-empty string.')
200
+ return value
201
+ }
202
+
203
+ function textList(value, file, field) {
204
+ if (value == null) return []
205
+ const list = Array.isArray(value) ? value : [value]
206
+ if (!list.every(isText)) throw invalid(file, field, 'must be a list of non-empty strings.')
207
+ return list
208
+ }
209
+
210
+ export function readContract(source, file = CONTRACT_FILE) {
211
+ const raw = parseYaml(source, file)
212
+ if (!isMapping(raw)) throw new ContractError(`${file}: expected a mapping with \`version\`, \`source\` and \`owned\`.`)
213
+ if (raw.version == null) throw invalid(file, 'version', 'is required.')
214
+ if (String(raw.version) !== '1') throw invalid(file, 'version', `is ${raw.version}; this harness reads version 1.`)
215
+ const origin = mapping(raw.source, file, 'source', true)
216
+ const url = text(origin.url, file, 'source.url')
217
+ const owned = textList(raw.owned, file, 'owned')
218
+ if (!owned.length) throw invalid(file, 'owned', 'is required and must list at least one path.')
219
+ const reuse = mapping(raw.reuse, file, 'reuse')
220
+ const sync = mapping(raw.sync, file, 'sync')
221
+ const version = sync.version == null ? null : mapping(sync.version, file, 'sync.version')
222
+ return {
223
+ file,
224
+ source: { name: text(origin.name, file, 'source.name'), url, changes: text(origin.changes, file, 'source.changes', url) },
225
+ owned,
226
+ reuse: { docs: textList(reuse.docs, file, 'reuse.docs'), packages: textList(reuse.packages, file, 'reuse.packages'), names: textList(reuse.names, file, 'reuse.names') },
227
+ exceptions: text(raw.exceptions, file, 'exceptions', DEFAULT_EXCEPTIONS),
228
+ sync: {
229
+ marker: text(sync.marker, file, 'sync.marker', null),
230
+ version: version && { file: text(version.file, file, 'sync.version.file'), key: text(version.key, file, 'sync.version.key') },
231
+ update: text(sync.update, file, 'sync.update', null),
232
+ },
233
+ }
234
+ }
235
+
236
+ function readText(file) {
237
+ try {
238
+ return fs.readFileSync(file, 'utf8')
239
+ } catch {
240
+ return null
241
+ }
242
+ }
243
+
244
+ // What harness 3.7.0 read before contract.yml existed: Copier answers plus a manifest of owned globs.
245
+ function legacyContract(root) {
246
+ const answers = readText(path.join(root, LEGACY_ANSWERS))
247
+ const manifest = answers == null ? null : readText(path.join(root, LEGACY_MANIFEST))
248
+ if (manifest == null) return null
249
+ const srcLine = answers.match(/^_src_path:(.*)$/m)
250
+ const url = srcLine ? scalar(stripComment(srcLine[1])) || null : null
251
+ const owned = manifest.split(/\r?\n/).map(line => line.replace(/(?<!\\)\s+$/, '')).filter(line => line && !line.startsWith('#'))
252
+ return {
253
+ file: LEGACY_MANIFEST,
254
+ source: { name: url ? url.replace(/\.git$/, '').split(/[\\/:]/).filter(Boolean).pop() : 'the upstream template', url, changes: url },
255
+ owned,
256
+ reuse: { docs: [], packages: [], names: [] },
257
+ exceptions: DEFAULT_EXCEPTIONS,
258
+ sync: { marker: LEGACY_ANSWERS, version: { file: LEGACY_ANSWERS, key: '_commit' }, update: 'copier update --trust --defaults --conflict inline' },
259
+ }
260
+ }
261
+
262
+ export function loadContract(root) {
263
+ const source = readText(path.join(root, CONTRACT_FILE))
264
+ const contract = source == null ? legacyContract(root) : readContract(source)
265
+ if (!contract) return null
266
+ const exceptions = readText(path.join(root, contract.exceptions))
267
+ const entries = exceptions == null ? null : parseYaml(exceptions, contract.exceptions)?.exceptions
268
+ return { ...contract, rules: toRules(contract.owned), exceptionList: Array.isArray(entries) ? entries.filter(isMapping) : [] }
269
+ }
270
+
271
+ const localDate = (now = new Date()) =>
272
+ `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
273
+
274
+ export function isOwned(contract, file, today = localDate()) {
275
+ const relative = file.replace(/\\/g, '/').replace(/^\.\//, '')
276
+ if (!matchesRules(contract.rules, relative)) return false
277
+ return !contract.exceptionList.some(e =>
278
+ isText(e.path) && /^\d{4}-\d{2}-\d{2}$/.test(String(e.expires ?? '')) && e.expires >= today && matchGlob(e.path, relative))
279
+ }
280
+
281
+ export function describeOwner(contract) {
282
+ const { name, changes } = contract.source
283
+ return {
284
+ owner: `owned by ${name} (listed in ${contract.file})`,
285
+ fix: `Ask for the change ${changes ? `at ${changes}` : `in ${name}`}, or add a dated exception to ${contract.exceptions}.`,
286
+ }
287
+ }
288
+
289
+ export const nameKey = name => name.toLowerCase().replace(/[-_.\s]/g, '')
290
+
291
+ export function declaredExports(declarations) {
292
+ const names = [...declarations.matchAll(/^export\s+(?:declare\s+)?(?:default\s+)?(?:abstract\s+)?(?:function\*?|const|let|var|class|interface|type|enum|namespace)\s+([A-Za-z_$][\w$]*)/gm)].map(m => m[1])
293
+ for (const list of declarations.matchAll(/^export\s+(?:type\s+)?\{([^}]*)\}/gm)) {
294
+ for (const item of list[1].split(',')) {
295
+ const name = item.trim().replace(/^type\s+/, '').split(/\s+as\s+/).pop()
296
+ if (name && name !== 'default') names.push(name)
297
+ }
298
+ }
299
+ return names
300
+ }
301
+
302
+ function packageNames(directory) {
303
+ let manifest
304
+ try {
305
+ manifest = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8'))
306
+ } catch {
307
+ return []
308
+ }
309
+ const exportsMap = isMapping(manifest.exports) ? manifest.exports : {}
310
+ const subpaths = Object.keys(exportsMap)
311
+ .filter(key => key.startsWith('./') && !key.includes('*') && !/\.[a-z]+$/i.test(key))
312
+ .map(key => path.posix.basename(key))
313
+ const root = exportsMap['.'] ?? exportsMap
314
+ const types = manifest.types ?? manifest.typings ?? (isText(root.types) ? root.types : null)
315
+ const declarations = isText(types) ? readText(path.join(directory, types)) : null
316
+ return [...subpaths, ...(declarations ? declaredExports(declarations) : [])]
317
+ }
318
+
319
+ // Packages resolve from the nearest node_modules, so a workspace app finds what its own package.json installs.
320
+ export function reusableNames(root, contract, files = []) {
321
+ const names = new Map()
322
+ const add = (name, origin) => {
323
+ const key = nameKey(name)
324
+ if (key && !names.has(key)) names.set(key, { name, origin })
325
+ }
326
+ for (const name of contract.reuse.names) add(name, contract.file)
327
+ const base = path.resolve(root)
328
+ const directories = new Set([base])
329
+ for (const file of files) {
330
+ for (let dir = path.dirname(path.resolve(base, file)); dir.length > base.length && dir.startsWith(base); dir = path.dirname(dir)) directories.add(dir)
331
+ }
332
+ for (const pkg of contract.reuse.packages) {
333
+ for (const dir of directories) {
334
+ const installed = path.join(dir, 'node_modules', pkg)
335
+ if (fs.existsSync(path.join(installed, 'package.json'))) for (const name of packageNames(installed)) add(name, pkg)
336
+ }
337
+ }
338
+ return names
339
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/agent-harness",
3
- "version": "3.7.0",
3
+ "version": "3.8.1",
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,7 +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
+ - When `.platform/contract.yml` lists paths under `reuse.docs` (`docs/platform/`, for example), the template named in its `source` owns the code those pages document. For those topics link to the page instead of describing them again: a second description drifts on the next template update.
16
16
  - The footer is the file's last line, and it is the run's own ISO timestamp: `<!-- Last updated: <ISO date> -->`.
17
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.
18
18
 
@@ -39,6 +39,8 @@ Core rule: push, don't pull. A worker is born with its work: every `task()` spaw
39
39
 
40
40
  **2. Load the plan and workers.** Parse `tasks.md`. Each task carries `<!-- agent, depends_on, touches -->` (from `pc-plan-propose`). Inspect `.opencode/agents/` for each base engineer and its generated `.<tier>.md` variants. The tier-suffixed name in an annotation (for example, `backend-engineer.build`) is the worker to spawn: `pc-subagent-tiers` resolves its model at startup and registers it as `mode: subagent`. Read `.opencode/harness.json` -> `agents.maxConcurrent` (the wave cap, 1 to 5, enforced by `pc-subagent-monitor`).
41
41
 
42
+ When `.platform/contract.yml` declares `reuse` and the change's `proposal.md` has no `## What is reused` section, stop the APPLY stage and report it: the plan never checked what the template already ships. Otherwise every spawn prompt in step 7 carries that section.
43
+
42
44
  Before hydrating the Todo board, resolve every task's annotated worker. If any task has a blank agent annotation, its base template is missing, or its tier variant is unavailable, stop the APPLY stage and report the task ID, expected worker, and missing file. Do not replace the worker with `fullstack-engineer`, `general`, or the lead session.
43
45
 
44
46
  **3. Hydrate the Todo board.** `todowrite` one item per task: `pending`. The Todo pane is the visible subagent board (opencode plugins cannot draw a custom pane, so the native Todo widget is the live UI). While a task is in flight, its label must carry the worker: `<agent> · <model>`: so the pane shows which agent on which model is doing what. The Todo list is a projection only: never read it for recovery; rebuild it from `tasks.md` and git and `.opencode/harness-run.json`.
@@ -51,6 +51,8 @@ If a work item URL or issue key is provided (GitHub Issue, Azure DevOps work ite
51
51
 
52
52
  Load `@openspec-propose` skill and follow its instructions to generate proposal.md, specs, and tasks.md. Do not write them to disk yet. Build the complete proposal content in your context.
53
53
 
54
+ When `.platform/contract.yml` declares `reuse`, read the paths in `reuse.docs` and the exports of the `reuse.packages` first, and give proposal.md a `## What is reused` section: for each part of the change, the doc page, package export or `reuse.names` entry it builds on, or why none fits. A proposal without it re-implements what the template already ships, and `pc-plan-apply` stops on it.
55
+
54
56
  ## Step 2: Enrich task assignments
55
57
 
56
58
  1. List every `*-engineer.md` file in `.opencode/agents/`. For each file read:
@@ -1,112 +0,0 @@
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
- }