@plainconceptsplatform/agent-harness 3.7.0 → 3.8.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 +85 -7
- package/cli/steps/copy/agents.js +40 -1
- package/cli/utils/paths.js +1 -0
- package/harness/.opencode/plugins/pc-system-reminders.js +36 -16
- package/harness/.opencode/scripts/check-diff.mjs +38 -13
- package/harness/.opencode/scripts/platform-contract.mjs +287 -0
- package/package.json +1 -1
- package/skills/pc-make-architecture/SKILL.md +1 -1
- package/skills/pc-plan-apply/SKILL.md +2 -0
- package/skills/pc-plan-propose/SKILL.md +2 -0
- package/harness/.opencode/scripts/platform-manifest.mjs +0 -112
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-
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
401
|
+
- path: src/platform/audit/**
|
|
339
402
|
reason: Local audit sink until the template supports it
|
|
340
|
-
issue: https://github.com/
|
|
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
|
|
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
|
|
package/cli/steps/copy/agents.js
CHANGED
|
@@ -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
|
-
|
|
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}`)
|
package/cli/utils/paths.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
return
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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 =>
|
|
225
|
-
.map(file => ({ rule: 'platform-file', file: file.path,
|
|
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
|
-
|
|
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,287 @@
|
|
|
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
|
+
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
|
+
}
|
|
32
|
+
}
|
|
33
|
+
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)
|
|
43
|
+
}
|
|
44
|
+
return new RegExp(`^${anchored ? '' : '(?:.*/)?'}${source}${directory ? '/.*' : '(?:/.*)?'}$`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const toRules = globs => globs.map(glob => {
|
|
48
|
+
const negated = glob.startsWith('!')
|
|
49
|
+
return { negated, regex: globToRegExp(negated ? glob.slice(1) : glob) }
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
function matchesRules(rules, file) {
|
|
53
|
+
let matched = false
|
|
54
|
+
for (const rule of rules) if (rule.regex.test(file)) matched = !rule.negated
|
|
55
|
+
return matched
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function stripComment(line) {
|
|
59
|
+
let quote = null
|
|
60
|
+
for (let i = 0; i < line.length; i++) {
|
|
61
|
+
const c = line[i]
|
|
62
|
+
if (quote) quote = c === quote ? null : quote
|
|
63
|
+
else if ((c === '"' || c === "'") && (i === 0 || /[\s[,]/.test(line[i - 1]))) quote = c
|
|
64
|
+
else if (c === '#' && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i)
|
|
65
|
+
}
|
|
66
|
+
return line
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function scalar(raw) {
|
|
70
|
+
const value = raw.trim()
|
|
71
|
+
const quoted = value.match(/^(["'])(.*)\1$/)
|
|
72
|
+
if (quoted) return quoted[2]
|
|
73
|
+
if (value.startsWith('[') && value.endsWith(']')) return value.slice(1, -1).split(',').map(scalar).filter(item => item !== '')
|
|
74
|
+
return /^-?\d+$/.test(value) ? Number(value) : value
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const isItem = text => text === '-' || text.startsWith('- ')
|
|
78
|
+
|
|
79
|
+
// The subset of YAML a contract and its exceptions use: nested mappings, block and flow lists, scalars.
|
|
80
|
+
export function parseYaml(text, file) {
|
|
81
|
+
const lines = []
|
|
82
|
+
text.replace(/^\uFEFF/, '').split(/\r?\n/).forEach((raw, index) => {
|
|
83
|
+
const body = stripComment(raw).trimEnd()
|
|
84
|
+
if (body.trim()) lines.push({ indent: body.length - body.trimStart().length, text: body.trim(), number: index + 1 })
|
|
85
|
+
})
|
|
86
|
+
const fail = line => {
|
|
87
|
+
throw new ContractError(`${file}: line ${line.number} cannot be read: \`${line.text}\`.`)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function block(start, indent) {
|
|
91
|
+
const list = isItem(lines[start].text)
|
|
92
|
+
const value = list ? [] : {}
|
|
93
|
+
let i = start
|
|
94
|
+
while (i < lines.length && lines[i].indent === indent) {
|
|
95
|
+
const line = lines[i]
|
|
96
|
+
if (isItem(line.text) !== list) {
|
|
97
|
+
if (list) break
|
|
98
|
+
fail(line)
|
|
99
|
+
}
|
|
100
|
+
if (list) {
|
|
101
|
+
const rest = line.text.slice(1).trimStart()
|
|
102
|
+
if (/^[^"'[{][^:]*:(\s|$)/.test(rest)) {
|
|
103
|
+
lines[i] = { ...line, indent: indent + line.text.length - rest.length, text: rest }
|
|
104
|
+
} else {
|
|
105
|
+
i++
|
|
106
|
+
if (rest || !(i < lines.length && lines[i].indent > indent)) {
|
|
107
|
+
value.push(rest ? scalar(rest) : null)
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const [item, after] = block(i, lines[i].indent)
|
|
112
|
+
value.push(item)
|
|
113
|
+
i = after
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
const pair = line.text.match(/^([^:]+?)\s*:(?:\s+(.*))?$/)
|
|
117
|
+
if (!pair) fail(line)
|
|
118
|
+
const key = scalar(pair[1])
|
|
119
|
+
i++
|
|
120
|
+
const next = lines[i]
|
|
121
|
+
if (pair[2]) value[key] = scalar(pair[2])
|
|
122
|
+
else if (next && (next.indent > indent || (next.indent === indent && isItem(next.text)))) [value[key], i] = block(i, next.indent)
|
|
123
|
+
else value[key] = null
|
|
124
|
+
}
|
|
125
|
+
if (i < lines.length && lines[i].indent > indent) fail(lines[i])
|
|
126
|
+
return [value, i]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!lines.length) return null
|
|
130
|
+
const [value, end] = block(0, lines[0].indent)
|
|
131
|
+
if (end < lines.length) fail(lines[end])
|
|
132
|
+
return value
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const isText = value => typeof value === 'string' && value.trim() !== ''
|
|
136
|
+
const isMapping = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
137
|
+
const invalid = (file, field, problem) => new ContractError(`${file}: \`${field}\` ${problem}`)
|
|
138
|
+
|
|
139
|
+
function mapping(value, file, field, required = false) {
|
|
140
|
+
if (value == null && !required) return {}
|
|
141
|
+
if (!isMapping(value)) throw invalid(file, field, required ? 'is required and must be a mapping.' : 'must be a mapping.')
|
|
142
|
+
return value
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function text(value, file, field, fallback) {
|
|
146
|
+
if (value == null && fallback !== undefined) return fallback
|
|
147
|
+
if (!isText(value)) throw invalid(file, field, fallback === undefined ? 'is required and must be a non-empty string.' : 'must be a non-empty string.')
|
|
148
|
+
return value
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function textList(value, file, field) {
|
|
152
|
+
if (value == null) return []
|
|
153
|
+
const list = Array.isArray(value) ? value : [value]
|
|
154
|
+
if (!list.every(isText)) throw invalid(file, field, 'must be a list of non-empty strings.')
|
|
155
|
+
return list
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function readContract(source, file = CONTRACT_FILE) {
|
|
159
|
+
const raw = parseYaml(source, file)
|
|
160
|
+
if (!isMapping(raw)) throw new ContractError(`${file}: expected a mapping with \`version\`, \`source\` and \`owned\`.`)
|
|
161
|
+
if (raw.version == null) throw invalid(file, 'version', 'is required.')
|
|
162
|
+
if (String(raw.version) !== '1') throw invalid(file, 'version', `is ${raw.version}; this harness reads version 1.`)
|
|
163
|
+
const origin = mapping(raw.source, file, 'source', true)
|
|
164
|
+
const url = text(origin.url, file, 'source.url')
|
|
165
|
+
const owned = textList(raw.owned, file, 'owned')
|
|
166
|
+
if (!owned.length) throw invalid(file, 'owned', 'is required and must list at least one path.')
|
|
167
|
+
const reuse = mapping(raw.reuse, file, 'reuse')
|
|
168
|
+
const sync = mapping(raw.sync, file, 'sync')
|
|
169
|
+
const version = sync.version == null ? null : mapping(sync.version, file, 'sync.version')
|
|
170
|
+
return {
|
|
171
|
+
file,
|
|
172
|
+
source: { name: text(origin.name, file, 'source.name'), url, changes: text(origin.changes, file, 'source.changes', url) },
|
|
173
|
+
owned,
|
|
174
|
+
reuse: { docs: textList(reuse.docs, file, 'reuse.docs'), packages: textList(reuse.packages, file, 'reuse.packages'), names: textList(reuse.names, file, 'reuse.names') },
|
|
175
|
+
exceptions: text(raw.exceptions, file, 'exceptions', DEFAULT_EXCEPTIONS),
|
|
176
|
+
sync: {
|
|
177
|
+
marker: text(sync.marker, file, 'sync.marker', null),
|
|
178
|
+
version: version && { file: text(version.file, file, 'sync.version.file'), key: text(version.key, file, 'sync.version.key') },
|
|
179
|
+
update: text(sync.update, file, 'sync.update', null),
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function readText(file) {
|
|
185
|
+
try {
|
|
186
|
+
return fs.readFileSync(file, 'utf8')
|
|
187
|
+
} catch {
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// What harness 3.7.0 read before contract.yml existed: Copier answers plus a manifest of owned globs.
|
|
193
|
+
function legacyContract(root) {
|
|
194
|
+
const answers = readText(path.join(root, LEGACY_ANSWERS))
|
|
195
|
+
const manifest = answers == null ? null : readText(path.join(root, LEGACY_MANIFEST))
|
|
196
|
+
if (manifest == null) return null
|
|
197
|
+
const srcLine = answers.match(/^_src_path:(.*)$/m)
|
|
198
|
+
const url = srcLine ? scalar(stripComment(srcLine[1])) || null : null
|
|
199
|
+
const owned = manifest.split(/\r?\n/).map(line => line.replace(/(?<!\\)\s+$/, '')).filter(line => line && !line.startsWith('#'))
|
|
200
|
+
return {
|
|
201
|
+
file: LEGACY_MANIFEST,
|
|
202
|
+
source: { name: url ? url.replace(/\.git$/, '').split(/[\\/:]/).filter(Boolean).pop() : 'the upstream template', url, changes: url },
|
|
203
|
+
owned,
|
|
204
|
+
reuse: { docs: [], packages: [], names: [] },
|
|
205
|
+
exceptions: DEFAULT_EXCEPTIONS,
|
|
206
|
+
sync: { marker: LEGACY_ANSWERS, version: { file: LEGACY_ANSWERS, key: '_commit' }, update: 'copier update --trust --defaults --conflict inline' },
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function loadContract(root) {
|
|
211
|
+
const source = readText(path.join(root, CONTRACT_FILE))
|
|
212
|
+
const contract = source == null ? legacyContract(root) : readContract(source)
|
|
213
|
+
if (!contract) return null
|
|
214
|
+
const exceptions = readText(path.join(root, contract.exceptions))
|
|
215
|
+
const entries = exceptions == null ? null : parseYaml(exceptions, contract.exceptions)?.exceptions
|
|
216
|
+
return { ...contract, rules: toRules(contract.owned), exceptionList: Array.isArray(entries) ? entries.filter(isMapping) : [] }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const localDate = (now = new Date()) =>
|
|
220
|
+
`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
|
221
|
+
|
|
222
|
+
export function isOwned(contract, file, today = localDate()) {
|
|
223
|
+
const relative = file.replace(/\\/g, '/').replace(/^\.\//, '')
|
|
224
|
+
if (!matchesRules(contract.rules, relative)) return false
|
|
225
|
+
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))
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function describeOwner(contract) {
|
|
230
|
+
const { name, changes } = contract.source
|
|
231
|
+
return {
|
|
232
|
+
owner: `owned by ${name} (listed in ${contract.file})`,
|
|
233
|
+
fix: `Ask for the change ${changes ? `at ${changes}` : `in ${name}`}, or add a dated exception to ${contract.exceptions}.`,
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export const nameKey = name => name.toLowerCase().replace(/[-_.\s]/g, '')
|
|
238
|
+
|
|
239
|
+
export function declaredExports(declarations) {
|
|
240
|
+
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])
|
|
241
|
+
for (const list of declarations.matchAll(/^export\s+(?:type\s+)?\{([^}]*)\}/gm)) {
|
|
242
|
+
for (const item of list[1].split(',')) {
|
|
243
|
+
const name = item.trim().replace(/^type\s+/, '').split(/\s+as\s+/).pop()
|
|
244
|
+
if (name && name !== 'default') names.push(name)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return names
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function packageNames(directory) {
|
|
251
|
+
let manifest
|
|
252
|
+
try {
|
|
253
|
+
manifest = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8'))
|
|
254
|
+
} catch {
|
|
255
|
+
return []
|
|
256
|
+
}
|
|
257
|
+
const exportsMap = isMapping(manifest.exports) ? manifest.exports : {}
|
|
258
|
+
const subpaths = Object.keys(exportsMap)
|
|
259
|
+
.filter(key => key.startsWith('./') && !key.includes('*') && !/\.[a-z]+$/i.test(key))
|
|
260
|
+
.map(key => path.posix.basename(key))
|
|
261
|
+
const root = exportsMap['.'] ?? exportsMap
|
|
262
|
+
const types = manifest.types ?? manifest.typings ?? (isText(root.types) ? root.types : null)
|
|
263
|
+
const declarations = isText(types) ? readText(path.join(directory, types)) : null
|
|
264
|
+
return [...subpaths, ...(declarations ? declaredExports(declarations) : [])]
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Packages resolve from the nearest node_modules, so a workspace app finds what its own package.json installs.
|
|
268
|
+
export function reusableNames(root, contract, files = []) {
|
|
269
|
+
const names = new Map()
|
|
270
|
+
const add = (name, origin) => {
|
|
271
|
+
const key = nameKey(name)
|
|
272
|
+
if (key && !names.has(key)) names.set(key, { name, origin })
|
|
273
|
+
}
|
|
274
|
+
for (const name of contract.reuse.names) add(name, contract.file)
|
|
275
|
+
const base = path.resolve(root)
|
|
276
|
+
const directories = new Set([base])
|
|
277
|
+
for (const file of files) {
|
|
278
|
+
for (let dir = path.dirname(path.resolve(base, file)); dir.length > base.length && dir.startsWith(base); dir = path.dirname(dir)) directories.add(dir)
|
|
279
|
+
}
|
|
280
|
+
for (const pkg of contract.reuse.packages) {
|
|
281
|
+
for (const dir of directories) {
|
|
282
|
+
const installed = path.join(dir, 'node_modules', pkg)
|
|
283
|
+
if (fs.existsSync(path.join(installed, 'package.json'))) for (const name of packageNames(installed)) add(name, pkg)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return names
|
|
287
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plainconceptsplatform/agent-harness",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.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,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 `
|
|
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
|
-
}
|