@abarcenas/ai-workflow-template 1.46.0 → 1.47.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/.agents/instructions/learned-knowledge.instructions.md +18 -0
- package/.prettierignore +26 -0
- package/AGENTS.md +4 -0
- package/README.md +24 -0
- package/opencode.mcp.example.json +1 -0
- package/package.json +3 -1
- package/scripts/sync-unit.test.js +551 -12
- package/scripts/sync.js +296 -13
|
@@ -577,3 +577,21 @@
|
|
|
577
577
|
- **coder (index audits)**: Enumerate all mutating paths — create, update, shrink, delete-source, failure — then assert the same invariant helper on each. The bug was in the paths that had never been tested, not in the one that had.
|
|
578
578
|
- **deployer**: This was a **follow-up fix** to a bug deliberately recorded as open in commit `308c90f`; the tracker entry must explicitly mark that follow-up **resolved** so the audit trail does not keep an open defect that no longer exists. Re-run all three gates before and after, stage only the enumerated files, and use `SKIP_BUMP=1` so the fix does not bump the minor version.
|
|
579
579
|
- **all agents (knowledge retrieval)**: `memory_search` returned "No results found. The index may be empty"; `knowledgebase_search` was **not executed** (PG vector unavailable — `DATABASE_URL` not configured). Both reported as skipped/unavailable, neither retried, neither treated as a task failure.
|
|
580
|
+
|
|
581
|
+
## Session: 2026-10-25 — Project-Owned Layer for Sync (ownership before force; formatter freeze hazard)
|
|
582
|
+
|
|
583
|
+
**Pipeline:** implementer → coder → reviewer (round 1: APPROVE WITH CHANGES) → coder → reviewer (round 2: BLOCK) → coder (round 3 fixes) → tracker
|
|
584
|
+
**Scope:** `scripts/sync.js`, `scripts/sync-unit.test.js`, `.prettierignore`, `AGENTS.md`, `README.md`, `package.json`, `opencode.mcp.example.json`
|
|
585
|
+
**Verification:** 26 files / **790 tests**, 0 failures; coverage **96.88 / 94.9 / 96.87 / 97.09**; `npm pack --dry-run` ships `.prettierignore` and excludes `AGENTS.project.md` and `opencode.json`; consumer repos untouched (apmc-cms: only its three pre-existing modified files; apmc-v2 clean).
|
|
586
|
+
|
|
587
|
+
**New knowledge:**
|
|
588
|
+
- **Sync mechanics, decided once and recorded here:** the overwrite policy is **hash-equality**; a file whose hash does not match is skipped **permanently** and only refreshes if the consumer writes it again. `--force` is destructive with **no backup**. Prune is **opt-in** and is never passed by the setup command. `opencode.json` looked managed (it sat in `rootFiles` at HEAD) but was excluded from the tarball with no manifest key, so it was effectively scaffold-once — **verify tarball + manifest key + copy loop before assuming any file is managed.**
|
|
589
|
+
- **Design pattern that worked:** check ownership **before** the `forceOverwrite || isUntouched` branch, and short-circuit prune on ownership **before** hashing. A guard placed after either decision point does not hold — the file is already copied or already queued for deletion. One exported `Set` matching channel-agnostically (root name also protects nested names) is deliberately over-conservative and easier to keep correct than four parallel checks.
|
|
590
|
+
- **Testing pattern that failed:** writing the shipped file verbatim as the test fixture only proved the parser agreed with itself — it passed while the detector was wrong in both directions. What caught it was a **table-driven matrix with both outcomes asserted** (false alarm *and* false all-clear) plus **parent-directory-protected, negated-hole, and real-consumer end-to-end fixtures**. A detector of "is the user's config adequate" needs both directions, or the vacuous case hides the bug.
|
|
591
|
+
- **Formatter hazard (consumer-fatal):** a repo-wide Prettier run over sync-managed files rewrites their hashes and is by itself sufficient to **permanently freeze** a consumer's updates. `.prettierignore` must be established **before** formatting, not added after the damage. Ship it, scaffold it if absent, and warn once when an existing one is inadequate.
|
|
592
|
+
|
|
593
|
+
**Agent tuning notes:**
|
|
594
|
+
- **implementer / coder**: asking an implementing agent to **pause when a heuristic exceeds a stated complexity threshold** produced an honest escalation instead of a silently wrong heuristic. Worth repeating for any "detect whether the user's config is adequate" feature: state the threshold up front and treat pausing as success, not failure.
|
|
595
|
+
- **reviewer (two-round shape)**: round 1 caught missing user-facing coverage; round 2 blocked the *new* mechanism for being wrong in both directions. A fix that introduces a heuristic deserves a dedicated review pass — reviewing it as part of the original change under-weights it.
|
|
596
|
+
- **coder (docs for honest gaps)**: protect-but-don't-honour (`.agents-sync-ignore`) must be stated plainly in the README and in code comments. Shipping the storage without the semantics is acceptable only when it is impossible to mistake for working.
|
|
597
|
+
- **all agents (knowledge retrieval)**: `memory_search` executed (cwd-scoped to this repo); `knowledgebase_search` **NOT executed** (PG vector unavailable — `DATABASE_URL` not configured). Neither retried nor treated as a task failure.
|
package/.prettierignore
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Project-owned. Sync never overwrites this file.
|
|
2
|
+
# Keep formatters away from files managed by @abarcenas/ai-workflow-template:
|
|
3
|
+
# a formatting pass over managed files makes every future sync skip them.
|
|
4
|
+
|
|
5
|
+
# Managed agent configuration
|
|
6
|
+
.agents/
|
|
7
|
+
.opencode/agents/
|
|
8
|
+
.opencode/commands/
|
|
9
|
+
|
|
10
|
+
# Managed root files
|
|
11
|
+
AGENTS.md
|
|
12
|
+
AGENTS.project.md
|
|
13
|
+
skills-lock.json
|
|
14
|
+
|
|
15
|
+
# Managed scripts (scripts/sync.js scriptsToSync)
|
|
16
|
+
scripts/memory-cli.js
|
|
17
|
+
scripts/memory-index.js
|
|
18
|
+
scripts/bump-version.js
|
|
19
|
+
scripts/validate-memory-schema.js
|
|
20
|
+
scripts/vocab-sync.js
|
|
21
|
+
scripts/mcp-memory-server.js
|
|
22
|
+
scripts/mcp/playwright-mcp-launcher.js
|
|
23
|
+
scripts/knowledgebase-cli.js
|
|
24
|
+
scripts/knowledgebase-index.js
|
|
25
|
+
scripts/mcp-knowledgebase-server.js
|
|
26
|
+
scripts/knowledgebase-init.sql
|
package/AGENTS.md
CHANGED
|
@@ -9,6 +9,10 @@ This project includes AI-assisted development configuration distributed through
|
|
|
9
9
|
- `.opencode/agents/` — Specialized agent definitions for planning, research, and implementation
|
|
10
10
|
- `.agents/prompts/` — Reusable prompt templates
|
|
11
11
|
|
|
12
|
+
## Project-Specific Rules
|
|
13
|
+
|
|
14
|
+
Put project-specific agent rules in `AGENTS.project.md` at the repository root. That file is project-owned: sync never overwrites or prunes it, and `--force` cannot replace it. New projects load it automatically through the `instructions` entry in `opencode.json`.
|
|
15
|
+
|
|
12
16
|
## Core Workflow
|
|
13
17
|
|
|
14
18
|
1. **Understand before acting** — Read relevant instructions from `.agents/instructions/` before working with specific technologies
|
package/README.md
CHANGED
|
@@ -75,8 +75,29 @@ npm run sync # Normal sync
|
|
|
75
75
|
- New files are copied automatically
|
|
76
76
|
- Unmodified files are refreshed from the package
|
|
77
77
|
- Locally edited files are preserved (skipped by default)
|
|
78
|
+
- Project-owned files are never overwritten or pruned after creation, and `--force` cannot replace them
|
|
78
79
|
- GitHub Actions workflows are excluded from consumer sync (`.github/workflows/`)
|
|
79
80
|
|
|
81
|
+
> `--dry-run` previews copies and prunes, but missing scaffold files (memory-bank stubs, `opencode.json`, `AGENTS.project.md`, `.prettierignore`) are still created. This matches the pre-existing scaffold behaviour.
|
|
82
|
+
|
|
83
|
+
### Project-owned files
|
|
84
|
+
|
|
85
|
+
`AGENTS.project.md` is the supported home for project-specific agent rules. It is created if absent, then treated as project-owned: sync never overwrites or prunes it, `--force` cannot replace it, and deleting it recreates it on the next sync. New projects load it through `"instructions": ["AGENTS.project.md"]` in `opencode.json`; a project created before that entry existed must add it once, because sync never rewrites an existing `opencode.json`.
|
|
86
|
+
|
|
87
|
+
The same ownership rule protects `opencode.json`, `.prettierignore`, and `.agents/instructions/learned-knowledge.instructions.md`. `.agents-sync-ignore` is also protected from overwrite and deletion, but sync does **not** read or honour its contents yet — do not rely on it.
|
|
88
|
+
|
|
89
|
+
`__project__/<file>` entries in `.agents-sync-manifest.json` are reporting metadata written only when sync creates the file. Files that already existed before the upgrade are intentionally absent, because sync has no managed baseline for them.
|
|
90
|
+
|
|
91
|
+
The package ships a `.prettierignore` and scaffolds it when absent. New projects get coverage automatically for `.agents/`, `.opencode/agents/`, `.opencode/commands/`, `AGENTS.md`, `skills-lock.json`, and every synced script; the shipped file also ignores `AGENTS.project.md`, though that file is not hash-managed. Existing projects keep their own `.prettierignore` untouched (it is project-owned), so if it does not cover every managed path sync prints one warning:
|
|
92
|
+
|
|
93
|
+
```text
|
|
94
|
+
[ai-workflow-template] Existing .prettierignore does not protect managed files; merge the patterns from node_modules/@abarcenas/ai-workflow-template/.prettierignore
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Merge those patterns manually — sync never edits the file. Template-side additions to `rootFiles` or `scriptsToSync` also require a matching manual line in an existing project; the shipped file only updates the defaults for new projects. A formatter run over managed files rewrites their hashes, so every later sync skips them and the consumer stops receiving template updates.
|
|
98
|
+
|
|
99
|
+
If sync warns `Skipped locally modified root file: AGENTS.md`, move those local rules into `AGENTS.project.md`, restore the managed file from `node_modules/@abarcenas/ai-workflow-template/AGENTS.md`, then re-run sync.
|
|
100
|
+
|
|
80
101
|
## Core Structure
|
|
81
102
|
|
|
82
103
|
Post-install, your project receives this structure:
|
|
@@ -90,6 +111,7 @@ Post-install, your project receives this structure:
|
|
|
90
111
|
.github/workflows/ # CI/CD automation (excluded from sync)
|
|
91
112
|
docs/ # Guides (e.g. playwright-mcp-configuration.md)
|
|
92
113
|
AGENTS.md # Master instruction file with agent guidance
|
|
114
|
+
AGENTS.project.md # Project-owned rules (recreated if deleted, never overwritten)
|
|
93
115
|
```
|
|
94
116
|
|
|
95
117
|
## Multi-Agent Orchestrator
|
|
@@ -286,6 +308,8 @@ npm run memory:normalize # Normalize frontmatter (runs automatically on instal
|
|
|
286
308
|
|
|
287
309
|
- Uses `INIT_CWD` to install against the consuming project (not the package dir)
|
|
288
310
|
- Local changes are always preserved unless `--force` is used
|
|
311
|
+
- Project-owned files (`AGENTS.project.md`, `opencode.json`, `.prettierignore`, learned knowledge) are created if absent and never overwritten or pruned after that, even with `--force`
|
|
312
|
+
- Do not add `AGENTS.project.md` or `opencode.json` to `package.json` `files`: both are scaffolded create-if-absent and must never ship from the package
|
|
289
313
|
- `AGENTS.md` is the main instruction file agents should read
|
|
290
314
|
- Package assumes the public npm registry
|
|
291
315
|
- Playwright MCP integration is documented in `docs/`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abarcenas/ai-workflow-template",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.47.0",
|
|
4
4
|
"description": "A comprehensive template repository for AI-assisted development workflows using GitHub Copilot. This template provides a structured approach to leveraging AI assistance through pre-configured skills, prompts, agents, and coding standards.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"url": "https://github.com/abarcenas29/ai-workflow-template/issues"
|
|
39
39
|
},
|
|
40
40
|
"homepage": "https://github.com/abarcenas29/ai-workflow-template#readme",
|
|
41
|
+
"//": "Never add AGENTS.project.md or opencode.json to files: both are scaffolded create-if-absent at the consumer root and must not ship from the package.",
|
|
41
42
|
"files": [
|
|
42
43
|
"bin/",
|
|
43
44
|
".agents/",
|
|
@@ -49,6 +50,7 @@
|
|
|
49
50
|
".husky/",
|
|
50
51
|
"memory-bank/.vocabulary.json",
|
|
51
52
|
"AGENTS.md",
|
|
53
|
+
".prettierignore",
|
|
52
54
|
"opencode.mcp.example.json",
|
|
53
55
|
"skills-lock.json",
|
|
54
56
|
".husky/post-merge",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
10
10
|
|
|
11
11
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
12
|
+
import { spawnSync } from 'child_process'
|
|
12
13
|
import { createHash } from 'crypto'
|
|
13
14
|
import {
|
|
14
15
|
chmodSync,
|
|
@@ -21,7 +22,7 @@ import {
|
|
|
21
22
|
symlinkSync,
|
|
22
23
|
writeFileSync,
|
|
23
24
|
} from 'fs'
|
|
24
|
-
import { dirname, join } from 'path'
|
|
25
|
+
import { dirname, join, resolve } from 'path'
|
|
25
26
|
import { tmpdir } from 'os'
|
|
26
27
|
import { fileURLToPath } from 'url'
|
|
27
28
|
|
|
@@ -35,13 +36,22 @@ import {
|
|
|
35
36
|
isRegularFile,
|
|
36
37
|
isUntouched,
|
|
37
38
|
memoryBankStubs,
|
|
39
|
+
missingPrettierIgnorePatterns,
|
|
38
40
|
parseArgs,
|
|
41
|
+
parsePrettierIgnoreRules,
|
|
42
|
+
projectOwnedPaths,
|
|
43
|
+
projectOwnedStubs,
|
|
39
44
|
readManifest,
|
|
45
|
+
requiredPrettierIgnorePatterns,
|
|
40
46
|
resolvePruneEntry,
|
|
47
|
+
rootFiles,
|
|
41
48
|
runSync,
|
|
49
|
+
scriptsToSync,
|
|
42
50
|
writeManifest,
|
|
43
51
|
} from './sync.js'
|
|
44
52
|
|
|
53
|
+
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
54
|
+
|
|
45
55
|
const tempDirs = []
|
|
46
56
|
|
|
47
57
|
function createTempDir() {
|
|
@@ -588,22 +598,32 @@ describe('resolvePruneEntry', () => {
|
|
|
588
598
|
)
|
|
589
599
|
})
|
|
590
600
|
|
|
591
|
-
it('
|
|
601
|
+
it('never resolves project-owned files as prune entries', () => {
|
|
592
602
|
const ctx = context()
|
|
593
603
|
|
|
594
|
-
const
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
604
|
+
for (const key of [
|
|
605
|
+
'__root__/AGENTS.project.md',
|
|
606
|
+
'__root__/opencode.json',
|
|
607
|
+
'__root__/.prettierignore',
|
|
608
|
+
'__root__/.agents-sync-ignore',
|
|
609
|
+
'instructions/learned-knowledge.instructions.md',
|
|
610
|
+
'__project__/AGENTS.project.md',
|
|
611
|
+
'__project__/.prettierignore',
|
|
612
|
+
]) {
|
|
613
|
+
expect(resolvePruneEntry(key, ctx), key).toBeNull()
|
|
614
|
+
}
|
|
600
615
|
})
|
|
601
616
|
|
|
602
617
|
it.each([
|
|
603
618
|
['unknown internal namespace', '__future__/thing.md'],
|
|
619
|
+
['scaffold-only project namespace', '__project__/AGENTS.project.md'],
|
|
604
620
|
['scaffold-only memory-bank namespace', '__memory-bank__/projectbrief.md'],
|
|
605
621
|
['scaffold-only instructions namespace', '__agents-instructions__/x.md'],
|
|
606
622
|
['excluded learned-knowledge file', 'instructions/learned-knowledge.instructions.md'],
|
|
623
|
+
['project-owned root AGENTS.project.md', '__root__/AGENTS.project.md'],
|
|
624
|
+
['project-owned root opencode.json', '__root__/opencode.json'],
|
|
625
|
+
['project-owned root .prettierignore', '__root__/.prettierignore'],
|
|
626
|
+
['project-owned root .agents-sync-ignore', '__root__/.agents-sync-ignore'],
|
|
607
627
|
['excluded opencode node_modules path', '__opencode__/node_modules/dep.js'],
|
|
608
628
|
['excluded opencode package.json', '__opencode__/package.json'],
|
|
609
629
|
['empty opencode key', '__opencode__/'],
|
|
@@ -664,7 +684,7 @@ describe('runSync copy passes', () => {
|
|
|
664
684
|
expect(result.exitCode).toBe(0)
|
|
665
685
|
expect(result.copied).toBe(9)
|
|
666
686
|
expect(result.added).toBe(9)
|
|
667
|
-
expect(result.scaffolded).toBe(
|
|
687
|
+
expect(result.scaffolded).toBe(10)
|
|
668
688
|
expect(result.skipped).toBe(0)
|
|
669
689
|
expect(errorSpy).not.toHaveBeenCalled()
|
|
670
690
|
|
|
@@ -757,12 +777,21 @@ describe('runSync copy passes', () => {
|
|
|
757
777
|
expect(manifest.files['__root__/AGENTS.md']).toBe(
|
|
758
778
|
sha256(ROOT_FILE_CONTENTS['AGENTS.md']),
|
|
759
779
|
)
|
|
760
|
-
expect(manifest.files['
|
|
780
|
+
expect(manifest.files['__project__/opencode.json']).toBe(sha256(MCP_EXAMPLE))
|
|
781
|
+
expect(manifest.files['__root__/opencode.json']).toBeUndefined()
|
|
761
782
|
expect(
|
|
762
783
|
manifest.files['__agents-instructions__/learned-knowledge.instructions.md'],
|
|
763
784
|
).toBe(
|
|
764
785
|
sha256(agentInstructionsStubs['learned-knowledge.instructions.md']),
|
|
765
786
|
)
|
|
787
|
+
expect(manifest.files['__project__/AGENTS.project.md']).toBe(
|
|
788
|
+
sha256(projectOwnedStubs['AGENTS.project.md']),
|
|
789
|
+
)
|
|
790
|
+
expect(manifest.files['__project__/.prettierignore']).toBe(
|
|
791
|
+
sha256(projectOwnedStubs['.prettierignore']),
|
|
792
|
+
)
|
|
793
|
+
expect(manifest.files['__root__/AGENTS.project.md']).toBeUndefined()
|
|
794
|
+
expect(manifest.files['__root__/.prettierignore']).toBeUndefined()
|
|
766
795
|
for (const fileName of Object.keys(memoryBankStubs)) {
|
|
767
796
|
expect(manifest.files[`__memory-bank__/${fileName}`]).toBeDefined()
|
|
768
797
|
}
|
|
@@ -866,7 +895,7 @@ describe('runSync copy passes', () => {
|
|
|
866
895
|
expect(outputOf(logSpy)).toContain('Mode: force.')
|
|
867
896
|
})
|
|
868
897
|
|
|
869
|
-
it('
|
|
898
|
+
it('never adopts a package-shipped opencode.json: the example scaffold wins', () => {
|
|
870
899
|
const packageRoot = createPackage({
|
|
871
900
|
agents: {},
|
|
872
901
|
root: { 'opencode.json': '{ "mcp": { "from": "package" } }\n' },
|
|
@@ -876,8 +905,10 @@ describe('runSync copy passes', () => {
|
|
|
876
905
|
|
|
877
906
|
runSync({ packageRoot, consumerRoot })
|
|
878
907
|
|
|
908
|
+
// opencode.json is project-owned, so the root copy pass skips it and the
|
|
909
|
+
// create-if-absent scaffold from the shipped example is what lands.
|
|
879
910
|
expect(readFileSync(join(consumerRoot, 'opencode.json'), 'utf8')).toBe(
|
|
880
|
-
|
|
911
|
+
MCP_EXAMPLE,
|
|
881
912
|
)
|
|
882
913
|
})
|
|
883
914
|
|
|
@@ -982,6 +1013,514 @@ describe('runSync copy passes', () => {
|
|
|
982
1013
|
})
|
|
983
1014
|
})
|
|
984
1015
|
|
|
1016
|
+
// ── Project-owned files ──────────────────────────────────────────────────────
|
|
1017
|
+
|
|
1018
|
+
const PROJECT_OWNED_LOCAL_CONTENT = {
|
|
1019
|
+
'AGENTS.project.md': '# Local project rules\n\n- never overwrite this',
|
|
1020
|
+
'opencode.json': '{ "local": true }\n',
|
|
1021
|
+
'.prettierignore': 'my-build/\n',
|
|
1022
|
+
'.agents-sync-ignore': '# local ignore rules\n',
|
|
1023
|
+
'.agents/instructions/learned-knowledge.instructions.md': '# Local knowledge\n',
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const LEGACY_PRETTIER_IGNORE =
|
|
1027
|
+
'/dist\n/coverage\n/.nx/cache\n/.nx/workspace-data\n'
|
|
1028
|
+
|
|
1029
|
+
const PRETTIER_IGNORE_WARNING =
|
|
1030
|
+
'[ai-workflow-template] Existing .prettierignore does not protect managed files; merge the patterns from node_modules/@abarcenas/ai-workflow-template/.prettierignore'
|
|
1031
|
+
|
|
1032
|
+
const COMPLETE_PRETTIER_IGNORE = [
|
|
1033
|
+
'.agents/',
|
|
1034
|
+
'.opencode/',
|
|
1035
|
+
'scripts/',
|
|
1036
|
+
'AGENTS.md',
|
|
1037
|
+
'skills-lock.json',
|
|
1038
|
+
].join('\n')
|
|
1039
|
+
|
|
1040
|
+
// B1 coverage matrix: parent-directory coverage, negation holes, strict
|
|
1041
|
+
// descendants, comments, inline literals, normalisation and ignore-all forms.
|
|
1042
|
+
const PRETTIER_IGNORE_MATRIX = [
|
|
1043
|
+
['exact shipped content', projectOwnedStubs['.prettierignore'], false],
|
|
1044
|
+
['parent directories cover descendants', COMPLETE_PRETTIER_IGNORE, false],
|
|
1045
|
+
[
|
|
1046
|
+
'shipped without AGENTS.md',
|
|
1047
|
+
projectOwnedStubs['.prettierignore'].replace('\nAGENTS.md\n', '\n'),
|
|
1048
|
+
true,
|
|
1049
|
+
],
|
|
1050
|
+
[
|
|
1051
|
+
'negation hole below .agents/',
|
|
1052
|
+
[
|
|
1053
|
+
'.agents/',
|
|
1054
|
+
'!.agents/skills/**',
|
|
1055
|
+
'.opencode/',
|
|
1056
|
+
'scripts/',
|
|
1057
|
+
'AGENTS.md',
|
|
1058
|
+
'skills-lock.json',
|
|
1059
|
+
].join('\n'),
|
|
1060
|
+
true,
|
|
1061
|
+
],
|
|
1062
|
+
[
|
|
1063
|
+
'later positive restores a negated subtree',
|
|
1064
|
+
[
|
|
1065
|
+
'.agents/',
|
|
1066
|
+
'!.agents/skills/**',
|
|
1067
|
+
'.agents/skills/',
|
|
1068
|
+
'.opencode/',
|
|
1069
|
+
'scripts/',
|
|
1070
|
+
'AGENTS.md',
|
|
1071
|
+
'skills-lock.json',
|
|
1072
|
+
].join('\n'),
|
|
1073
|
+
false,
|
|
1074
|
+
],
|
|
1075
|
+
[
|
|
1076
|
+
'AGENTS.md/** does not cover the file',
|
|
1077
|
+
COMPLETE_PRETTIER_IGNORE.replace('AGENTS.md', 'AGENTS.md/**'),
|
|
1078
|
+
true,
|
|
1079
|
+
],
|
|
1080
|
+
[
|
|
1081
|
+
'.agents without a trailing slash',
|
|
1082
|
+
COMPLETE_PRETTIER_IGNORE.replace('.agents/', '.agents'),
|
|
1083
|
+
false,
|
|
1084
|
+
],
|
|
1085
|
+
[
|
|
1086
|
+
'commented-out .agents/',
|
|
1087
|
+
COMPLETE_PRETTIER_IGNORE.replace('.agents/', '# .agents/'),
|
|
1088
|
+
true,
|
|
1089
|
+
],
|
|
1090
|
+
[
|
|
1091
|
+
'leading **/ wildcard',
|
|
1092
|
+
COMPLETE_PRETTIER_IGNORE.replace('AGENTS.md', '**/AGENTS.md'),
|
|
1093
|
+
false,
|
|
1094
|
+
],
|
|
1095
|
+
[
|
|
1096
|
+
'inline hash is literal',
|
|
1097
|
+
COMPLETE_PRETTIER_IGNORE.replace('AGENTS.md', 'AGENTS.md # managed'),
|
|
1098
|
+
true,
|
|
1099
|
+
],
|
|
1100
|
+
[
|
|
1101
|
+
'CRLF and trailing spaces',
|
|
1102
|
+
COMPLETE_PRETTIER_IGNORE.split('\n')
|
|
1103
|
+
.map((line) => `${line} `)
|
|
1104
|
+
.join('\r\n'),
|
|
1105
|
+
false,
|
|
1106
|
+
],
|
|
1107
|
+
[
|
|
1108
|
+
'leading-slash patterns (including /dist)',
|
|
1109
|
+
['/dist', '/.agents/', '/.opencode/', '/scripts/', '/AGENTS.md', '/skills-lock.json'].join(
|
|
1110
|
+
'\n',
|
|
1111
|
+
),
|
|
1112
|
+
false,
|
|
1113
|
+
],
|
|
1114
|
+
['* ignores everything', '*', false],
|
|
1115
|
+
['** ignores everything', '**', false],
|
|
1116
|
+
]
|
|
1117
|
+
|
|
1118
|
+
function createProjectOwnedFixture() {
|
|
1119
|
+
// The package ships competing copies at the project-owned paths. None may win.
|
|
1120
|
+
const packageRoot = createPackage({
|
|
1121
|
+
agents: {
|
|
1122
|
+
'instructions/learned-knowledge.instructions.md': '# Package knowledge\n',
|
|
1123
|
+
},
|
|
1124
|
+
root: {
|
|
1125
|
+
'AGENTS.project.md': '# Package rules\n',
|
|
1126
|
+
'opencode.json': '{ "package": true }\n',
|
|
1127
|
+
'.prettierignore': 'package-build/\n',
|
|
1128
|
+
'.agents-sync-ignore': '# package ignore\n',
|
|
1129
|
+
},
|
|
1130
|
+
mcpExample: MCP_EXAMPLE,
|
|
1131
|
+
})
|
|
1132
|
+
const consumerRoot = createTempDir()
|
|
1133
|
+
|
|
1134
|
+
for (const [relativePath, contents] of Object.entries(
|
|
1135
|
+
PROJECT_OWNED_LOCAL_CONTENT,
|
|
1136
|
+
)) {
|
|
1137
|
+
writeFixtureFile(consumerRoot, relativePath, contents)
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
return { packageRoot, consumerRoot }
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
describe('project-owned files', () => {
|
|
1144
|
+
it('scaffolds AGENTS.project.md and .prettierignore create-if-absent', () => {
|
|
1145
|
+
const { packageRoot, consumerRoot } = createCopyFixture()
|
|
1146
|
+
|
|
1147
|
+
const result = runSync({ packageRoot, consumerRoot })
|
|
1148
|
+
|
|
1149
|
+
expect(result.scaffolded).toBe(10)
|
|
1150
|
+
expect(readFileSync(join(consumerRoot, 'AGENTS.project.md'), 'utf8')).toBe(
|
|
1151
|
+
projectOwnedStubs['AGENTS.project.md'],
|
|
1152
|
+
)
|
|
1153
|
+
expect(readFileSync(join(consumerRoot, '.prettierignore'), 'utf8')).toBe(
|
|
1154
|
+
projectOwnedStubs['.prettierignore'],
|
|
1155
|
+
)
|
|
1156
|
+
|
|
1157
|
+
const manifest = readConsumerManifest(consumerRoot)
|
|
1158
|
+
expect(manifest.files['__project__/AGENTS.project.md']).toBe(
|
|
1159
|
+
sha256(projectOwnedStubs['AGENTS.project.md']),
|
|
1160
|
+
)
|
|
1161
|
+
expect(manifest.files['__project__/.prettierignore']).toBe(
|
|
1162
|
+
sha256(projectOwnedStubs['.prettierignore']),
|
|
1163
|
+
)
|
|
1164
|
+
// Ownership is reported through __project__, never through a sync channel.
|
|
1165
|
+
expect(manifest.files['__root__/AGENTS.project.md']).toBeUndefined()
|
|
1166
|
+
expect(manifest.files['__root__/.prettierignore']).toBeUndefined()
|
|
1167
|
+
})
|
|
1168
|
+
|
|
1169
|
+
it('does not overwrite existing project-owned files, even with --force', () => {
|
|
1170
|
+
const { packageRoot, consumerRoot } = createProjectOwnedFixture()
|
|
1171
|
+
|
|
1172
|
+
const result = runSync({
|
|
1173
|
+
packageRoot,
|
|
1174
|
+
consumerRoot,
|
|
1175
|
+
argv: ['--force'],
|
|
1176
|
+
env: {},
|
|
1177
|
+
})
|
|
1178
|
+
|
|
1179
|
+
expect(result.skipped).toBe(0)
|
|
1180
|
+
for (const [relativePath, contents] of Object.entries(
|
|
1181
|
+
PROJECT_OWNED_LOCAL_CONTENT,
|
|
1182
|
+
)) {
|
|
1183
|
+
expect(
|
|
1184
|
+
readFileSync(join(consumerRoot, relativePath), 'utf8'),
|
|
1185
|
+
relativePath,
|
|
1186
|
+
).toBe(contents)
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
const manifest = readConsumerManifest(consumerRoot)
|
|
1190
|
+
for (const key of [
|
|
1191
|
+
'__root__/AGENTS.project.md',
|
|
1192
|
+
'__root__/opencode.json',
|
|
1193
|
+
'__root__/.prettierignore',
|
|
1194
|
+
'__root__/.agents-sync-ignore',
|
|
1195
|
+
'__project__/AGENTS.project.md',
|
|
1196
|
+
'__project__/.prettierignore',
|
|
1197
|
+
'__project__/opencode.json',
|
|
1198
|
+
]) {
|
|
1199
|
+
expect(manifest.files[key], key).toBeUndefined()
|
|
1200
|
+
}
|
|
1201
|
+
})
|
|
1202
|
+
|
|
1203
|
+
it('preserves a local AGENTS.project.md edit byte-for-byte across a sync', () => {
|
|
1204
|
+
const { packageRoot, consumerRoot } = createCopyFixture()
|
|
1205
|
+
|
|
1206
|
+
runSync({ packageRoot, consumerRoot })
|
|
1207
|
+
const localEdit = '# Project rules\n\n- local edit, no trailing newline'
|
|
1208
|
+
writeFixtureFile(consumerRoot, 'AGENTS.project.md', localEdit)
|
|
1209
|
+
|
|
1210
|
+
const result = runSync({
|
|
1211
|
+
packageRoot,
|
|
1212
|
+
consumerRoot,
|
|
1213
|
+
argv: ['--force'],
|
|
1214
|
+
env: {},
|
|
1215
|
+
})
|
|
1216
|
+
|
|
1217
|
+
expect(result.skipped).toBe(0)
|
|
1218
|
+
expect(readFileSync(join(consumerRoot, 'AGENTS.project.md'), 'utf8')).toBe(
|
|
1219
|
+
localEdit,
|
|
1220
|
+
)
|
|
1221
|
+
})
|
|
1222
|
+
|
|
1223
|
+
it('never clobbers an existing consumer .prettierignore', () => {
|
|
1224
|
+
const { packageRoot, consumerRoot } = createCopyFixture()
|
|
1225
|
+
const localIgnore = 'coverage/\nplaywright-report/\n'
|
|
1226
|
+
writeFixtureFile(consumerRoot, '.prettierignore', localIgnore)
|
|
1227
|
+
|
|
1228
|
+
runSync({ packageRoot, consumerRoot })
|
|
1229
|
+
runSync({ packageRoot, consumerRoot, argv: ['--force'], env: {} })
|
|
1230
|
+
|
|
1231
|
+
expect(readFileSync(join(consumerRoot, '.prettierignore'), 'utf8')).toBe(
|
|
1232
|
+
localIgnore,
|
|
1233
|
+
)
|
|
1234
|
+
expect(
|
|
1235
|
+
readConsumerManifest(consumerRoot).files['__project__/.prettierignore'],
|
|
1236
|
+
).toBeUndefined()
|
|
1237
|
+
})
|
|
1238
|
+
|
|
1239
|
+
it('warns once when an existing .prettierignore does not protect managed files', () => {
|
|
1240
|
+
const { packageRoot, consumerRoot } = createCopyFixture()
|
|
1241
|
+
writeFixtureFile(consumerRoot, '.prettierignore', LEGACY_PRETTIER_IGNORE)
|
|
1242
|
+
|
|
1243
|
+
const result = runSync({ packageRoot, consumerRoot })
|
|
1244
|
+
|
|
1245
|
+
expect(result.scaffolded).toBe(9)
|
|
1246
|
+
expect(warnSpy).toHaveBeenCalledTimes(1)
|
|
1247
|
+
expect(outputOf(warnSpy)).toBe(PRETTIER_IGNORE_WARNING)
|
|
1248
|
+
// Warning only: the consumer file is byte-for-byte unchanged.
|
|
1249
|
+
expect(readFileSync(join(consumerRoot, '.prettierignore'), 'utf8')).toBe(
|
|
1250
|
+
LEGACY_PRETTIER_IGNORE,
|
|
1251
|
+
)
|
|
1252
|
+
})
|
|
1253
|
+
|
|
1254
|
+
it.skipIf(process.getuid?.() === 0)(
|
|
1255
|
+
'skips the advisory when .prettierignore cannot be read',
|
|
1256
|
+
() => {
|
|
1257
|
+
const { packageRoot, consumerRoot } = createCopyFixture()
|
|
1258
|
+
const ignorePath = writeFixtureFile(
|
|
1259
|
+
consumerRoot,
|
|
1260
|
+
'.prettierignore',
|
|
1261
|
+
LEGACY_PRETTIER_IGNORE,
|
|
1262
|
+
)
|
|
1263
|
+
chmodSync(ignorePath, 0o000)
|
|
1264
|
+
|
|
1265
|
+
try {
|
|
1266
|
+
const result = runSync({ packageRoot, consumerRoot })
|
|
1267
|
+
|
|
1268
|
+
expect(result.exitCode).toBe(0)
|
|
1269
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
1270
|
+
expect(existsSync(join(consumerRoot, '.agents-sync-manifest.json'))).toBe(
|
|
1271
|
+
true,
|
|
1272
|
+
)
|
|
1273
|
+
} finally {
|
|
1274
|
+
chmodSync(ignorePath, 0o644)
|
|
1275
|
+
}
|
|
1276
|
+
},
|
|
1277
|
+
)
|
|
1278
|
+
|
|
1279
|
+
it('does not warn when an existing .prettierignore covers managed files', () => {
|
|
1280
|
+
const { packageRoot, consumerRoot } = createCopyFixture()
|
|
1281
|
+
writeFixtureFile(
|
|
1282
|
+
consumerRoot,
|
|
1283
|
+
'.prettierignore',
|
|
1284
|
+
projectOwnedStubs['.prettierignore'],
|
|
1285
|
+
)
|
|
1286
|
+
|
|
1287
|
+
runSync({ packageRoot, consumerRoot })
|
|
1288
|
+
|
|
1289
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
1290
|
+
})
|
|
1291
|
+
|
|
1292
|
+
it('does not warn when .prettierignore was just scaffolded', () => {
|
|
1293
|
+
const { packageRoot, consumerRoot } = createCopyFixture()
|
|
1294
|
+
|
|
1295
|
+
runSync({ packageRoot, consumerRoot })
|
|
1296
|
+
|
|
1297
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
1298
|
+
expect(readFileSync(join(consumerRoot, '.prettierignore'), 'utf8')).toBe(
|
|
1299
|
+
projectOwnedStubs['.prettierignore'],
|
|
1300
|
+
)
|
|
1301
|
+
})
|
|
1302
|
+
|
|
1303
|
+
// Pins current behaviour: scaffolding is not gated by --dry-run. Update this
|
|
1304
|
+
// test when scaffolding becomes dry-run aware.
|
|
1305
|
+
it('documents that --dry-run still scaffolds missing project-owned files', () => {
|
|
1306
|
+
const { packageRoot, consumerRoot } = createCopyFixture()
|
|
1307
|
+
|
|
1308
|
+
const result = runSync({
|
|
1309
|
+
packageRoot,
|
|
1310
|
+
consumerRoot,
|
|
1311
|
+
argv: ['--dry-run'],
|
|
1312
|
+
env: {},
|
|
1313
|
+
})
|
|
1314
|
+
|
|
1315
|
+
expect(result.scaffolded).toBe(10)
|
|
1316
|
+
expect(readFileSync(join(consumerRoot, 'AGENTS.project.md'), 'utf8')).toBe(
|
|
1317
|
+
projectOwnedStubs['AGENTS.project.md'],
|
|
1318
|
+
)
|
|
1319
|
+
expect(readFileSync(join(consumerRoot, '.prettierignore'), 'utf8')).toBe(
|
|
1320
|
+
projectOwnedStubs['.prettierignore'],
|
|
1321
|
+
)
|
|
1322
|
+
expect(existsSync(join(consumerRoot, '.agents-sync-manifest.json'))).toBe(
|
|
1323
|
+
false,
|
|
1324
|
+
)
|
|
1325
|
+
})
|
|
1326
|
+
|
|
1327
|
+
it('does not refresh __project__ hashes after a consumer edit', () => {
|
|
1328
|
+
const { packageRoot, consumerRoot } = createCopyFixture()
|
|
1329
|
+
|
|
1330
|
+
runSync({ packageRoot, consumerRoot })
|
|
1331
|
+
const originalHash = readConsumerManifest(consumerRoot).files[
|
|
1332
|
+
'__project__/AGENTS.project.md'
|
|
1333
|
+
]
|
|
1334
|
+
writeFixtureFile(consumerRoot, 'AGENTS.project.md', '# edited locally\n')
|
|
1335
|
+
|
|
1336
|
+
runSync({ packageRoot, consumerRoot, argv: ['--force'], env: {} })
|
|
1337
|
+
|
|
1338
|
+
expect(
|
|
1339
|
+
readConsumerManifest(consumerRoot).files['__project__/AGENTS.project.md'],
|
|
1340
|
+
).toBe(originalHash)
|
|
1341
|
+
})
|
|
1342
|
+
|
|
1343
|
+
it('does not throw when a project-owned scaffold path is a directory', () => {
|
|
1344
|
+
const { packageRoot, consumerRoot } = createCopyFixture()
|
|
1345
|
+
mkdirSync(join(consumerRoot, 'AGENTS.project.md'), { recursive: true })
|
|
1346
|
+
mkdirSync(join(consumerRoot, '.prettierignore'), { recursive: true })
|
|
1347
|
+
|
|
1348
|
+
const result = runSync({ packageRoot, consumerRoot })
|
|
1349
|
+
|
|
1350
|
+
expect(result.exitCode).toBe(0)
|
|
1351
|
+
expect(existsSync(join(consumerRoot, '.agents-sync-manifest.json'))).toBe(
|
|
1352
|
+
true,
|
|
1353
|
+
)
|
|
1354
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
1355
|
+
})
|
|
1356
|
+
|
|
1357
|
+
it('never prunes any project-owned path, including with --prune', () => {
|
|
1358
|
+
const packageRoot = createPackage({ agents: {} })
|
|
1359
|
+
const consumerRoot = createTempDir()
|
|
1360
|
+
|
|
1361
|
+
for (const [relativePath, contents] of Object.entries(
|
|
1362
|
+
PROJECT_OWNED_LOCAL_CONTENT,
|
|
1363
|
+
)) {
|
|
1364
|
+
writeFixtureFile(consumerRoot, relativePath, contents)
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
const trackedKeys = {
|
|
1368
|
+
'__root__/AGENTS.project.md': 'AGENTS.project.md',
|
|
1369
|
+
'__root__/opencode.json': 'opencode.json',
|
|
1370
|
+
'__root__/.prettierignore': '.prettierignore',
|
|
1371
|
+
'__root__/.agents-sync-ignore': '.agents-sync-ignore',
|
|
1372
|
+
'instructions/learned-knowledge.instructions.md':
|
|
1373
|
+
'.agents/instructions/learned-knowledge.instructions.md',
|
|
1374
|
+
'__project__/AGENTS.project.md': 'AGENTS.project.md',
|
|
1375
|
+
'__project__/.prettierignore': '.prettierignore',
|
|
1376
|
+
}
|
|
1377
|
+
writeManifest(
|
|
1378
|
+
join(consumerRoot, '.agents-sync-manifest.json'),
|
|
1379
|
+
{
|
|
1380
|
+
version: 1,
|
|
1381
|
+
files: Object.fromEntries(
|
|
1382
|
+
Object.entries(trackedKeys).map(([key, relativePath]) => [
|
|
1383
|
+
key,
|
|
1384
|
+
sha256(PROJECT_OWNED_LOCAL_CONTENT[relativePath]),
|
|
1385
|
+
]),
|
|
1386
|
+
),
|
|
1387
|
+
},
|
|
1388
|
+
)
|
|
1389
|
+
|
|
1390
|
+
const result = runSync({
|
|
1391
|
+
packageRoot,
|
|
1392
|
+
consumerRoot,
|
|
1393
|
+
argv: ['--prune', '--force'],
|
|
1394
|
+
env: {},
|
|
1395
|
+
})
|
|
1396
|
+
|
|
1397
|
+
expect(result.pruned).toBe(0)
|
|
1398
|
+
for (const [relativePath, contents] of Object.entries(
|
|
1399
|
+
PROJECT_OWNED_LOCAL_CONTENT,
|
|
1400
|
+
)) {
|
|
1401
|
+
expect(
|
|
1402
|
+
readFileSync(join(consumerRoot, relativePath), 'utf8'),
|
|
1403
|
+
relativePath,
|
|
1404
|
+
).toBe(contents)
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
const manifest = readConsumerManifest(consumerRoot)
|
|
1408
|
+
for (const key of Object.keys(trackedKeys)) {
|
|
1409
|
+
expect(manifest.files, key).toHaveProperty([key])
|
|
1410
|
+
}
|
|
1411
|
+
})
|
|
1412
|
+
|
|
1413
|
+
it('keeps the shipped .prettierignore covering every managed root file and script', () => {
|
|
1414
|
+
const shipped = readFileSync(join(repoRoot, '.prettierignore'), 'utf8')
|
|
1415
|
+
|
|
1416
|
+
expect(shipped).toBe(projectOwnedStubs['.prettierignore'])
|
|
1417
|
+
expect(missingPrettierIgnorePatterns(shipped)).toEqual([])
|
|
1418
|
+
|
|
1419
|
+
for (const rootFile of rootFiles) {
|
|
1420
|
+
expect(
|
|
1421
|
+
missingPrettierIgnorePatterns(shipped, [rootFile]),
|
|
1422
|
+
rootFile,
|
|
1423
|
+
).toEqual([])
|
|
1424
|
+
}
|
|
1425
|
+
for (const script of scriptsToSync) {
|
|
1426
|
+
const pattern = `scripts/${script}`
|
|
1427
|
+
expect(
|
|
1428
|
+
missingPrettierIgnorePatterns(shipped, [pattern]),
|
|
1429
|
+
pattern,
|
|
1430
|
+
).toEqual([])
|
|
1431
|
+
}
|
|
1432
|
+
})
|
|
1433
|
+
|
|
1434
|
+
it.each(PRETTIER_IGNORE_MATRIX)(
|
|
1435
|
+
'detects coverage correctly: %s',
|
|
1436
|
+
(_label, contents, expectWarning) => {
|
|
1437
|
+
const { packageRoot, consumerRoot } = createCopyFixture()
|
|
1438
|
+
writeFixtureFile(consumerRoot, '.prettierignore', contents)
|
|
1439
|
+
|
|
1440
|
+
runSync({ packageRoot, consumerRoot })
|
|
1441
|
+
|
|
1442
|
+
expect(
|
|
1443
|
+
missingPrettierIgnorePatterns(contents).length > 0,
|
|
1444
|
+
'detector result',
|
|
1445
|
+
).toBe(expectWarning)
|
|
1446
|
+
if (expectWarning) {
|
|
1447
|
+
expect(warnSpy).toHaveBeenCalledTimes(1)
|
|
1448
|
+
expect(outputOf(warnSpy)).toBe(PRETTIER_IGNORE_WARNING)
|
|
1449
|
+
} else {
|
|
1450
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
1451
|
+
}
|
|
1452
|
+
},
|
|
1453
|
+
)
|
|
1454
|
+
|
|
1455
|
+
it('parses gitignore comments, negations and escaped markers', () => {
|
|
1456
|
+
expect(
|
|
1457
|
+
parsePrettierIgnoreRules(
|
|
1458
|
+
'# comment\n!AGENTS.md\n\\#literal\n\\!literal\nAGENTS.md # inline\n',
|
|
1459
|
+
),
|
|
1460
|
+
).toEqual([
|
|
1461
|
+
{ negated: true, pattern: 'AGENTS.md' },
|
|
1462
|
+
{ negated: false, pattern: '#literal' },
|
|
1463
|
+
{ negated: false, pattern: '!literal' },
|
|
1464
|
+
{ negated: false, pattern: 'AGENTS.md # inline' },
|
|
1465
|
+
])
|
|
1466
|
+
})
|
|
1467
|
+
|
|
1468
|
+
it('ships an opencode example that loads AGENTS.project.md', () => {
|
|
1469
|
+
const example = JSON.parse(
|
|
1470
|
+
readFileSync(join(repoRoot, 'opencode.mcp.example.json'), 'utf8'),
|
|
1471
|
+
)
|
|
1472
|
+
|
|
1473
|
+
expect(example.instructions).toEqual(['AGENTS.project.md'])
|
|
1474
|
+
})
|
|
1475
|
+
|
|
1476
|
+
it('scaffolds opencode.json with the project instructions entry', () => {
|
|
1477
|
+
const example = readFileSync(
|
|
1478
|
+
join(repoRoot, 'opencode.mcp.example.json'),
|
|
1479
|
+
'utf8',
|
|
1480
|
+
)
|
|
1481
|
+
const packageRoot = createPackage({ agents: {}, mcpExample: example })
|
|
1482
|
+
const consumerRoot = createTempDir()
|
|
1483
|
+
|
|
1484
|
+
runSync({ packageRoot, consumerRoot })
|
|
1485
|
+
|
|
1486
|
+
const config = JSON.parse(
|
|
1487
|
+
readFileSync(join(consumerRoot, 'opencode.json'), 'utf8'),
|
|
1488
|
+
)
|
|
1489
|
+
expect(config.instructions).toEqual(['AGENTS.project.md'])
|
|
1490
|
+
})
|
|
1491
|
+
|
|
1492
|
+
it('does not ship project-owned files in the npm package', () => {
|
|
1493
|
+
const packageManifest = JSON.parse(
|
|
1494
|
+
readFileSync(join(repoRoot, 'package.json'), 'utf8'),
|
|
1495
|
+
)
|
|
1496
|
+
expect(packageManifest.files).not.toContain('AGENTS.project.md')
|
|
1497
|
+
expect(packageManifest.files).not.toContain('opencode.json')
|
|
1498
|
+
expect(packageManifest.files).toContain('.prettierignore')
|
|
1499
|
+
|
|
1500
|
+
const packed = spawnSync('npm', ['pack', '--dry-run', '--json'], {
|
|
1501
|
+
cwd: repoRoot,
|
|
1502
|
+
encoding: 'utf8',
|
|
1503
|
+
})
|
|
1504
|
+
expect(packed.status).toBe(0)
|
|
1505
|
+
const files = JSON.parse(packed.stdout)[0].files.map((file) => file.path)
|
|
1506
|
+
expect(files).toContain('.prettierignore')
|
|
1507
|
+
expect(files).not.toContain('AGENTS.project.md')
|
|
1508
|
+
expect(files).not.toContain('opencode.json')
|
|
1509
|
+
})
|
|
1510
|
+
|
|
1511
|
+
it('reserves every documented project-owned path', () => {
|
|
1512
|
+
for (const relativePath of [
|
|
1513
|
+
'AGENTS.project.md',
|
|
1514
|
+
'opencode.json',
|
|
1515
|
+
'.prettierignore',
|
|
1516
|
+
'.agents-sync-ignore',
|
|
1517
|
+
'instructions/learned-knowledge.instructions.md',
|
|
1518
|
+
]) {
|
|
1519
|
+
expect(projectOwnedPaths.has(relativePath), relativePath).toBe(true)
|
|
1520
|
+
}
|
|
1521
|
+
})
|
|
1522
|
+
})
|
|
1523
|
+
|
|
985
1524
|
// ── Prune decisions ──────────────────────────────────────────────────────────
|
|
986
1525
|
|
|
987
1526
|
const PRUNE_CONTENT = {
|
package/scripts/sync.js
CHANGED
|
@@ -19,6 +19,27 @@ export const excludedRelativePaths = new Set([
|
|
|
19
19
|
'instructions/learned-knowledge.instructions.md',
|
|
20
20
|
])
|
|
21
21
|
|
|
22
|
+
// Files the consumer owns outright. Sync never copies, overwrites, or prunes
|
|
23
|
+
// them — not even with --force — because local edits are the point, not drift.
|
|
24
|
+
// Matching is channel-agnostic: every copy pass compares its own relative path
|
|
25
|
+
// against this set, so a root entry such as "opencode.json" also protects
|
|
26
|
+
// ".opencode/opencode.json" and ".agents/opencode.json". That over-match is
|
|
27
|
+
// intentional and conservative. The original learned-knowledge exclusion is
|
|
28
|
+
// spread in so there is one rule, not two parallel checks.
|
|
29
|
+
export const projectOwnedPaths = new Set([
|
|
30
|
+
'AGENTS.project.md',
|
|
31
|
+
'opencode.json',
|
|
32
|
+
'.prettierignore',
|
|
33
|
+
// Reserved: sync protects this file from overwrite and prune, but it does
|
|
34
|
+
// NOT read or honour its globs yet. Do not present it as functional.
|
|
35
|
+
'.agents-sync-ignore',
|
|
36
|
+
...excludedRelativePaths,
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
export function isProjectOwnedPath(relativePath) {
|
|
40
|
+
return projectOwnedPaths.has(relativePath)
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
export const openCodeExcluded = new Set([
|
|
23
44
|
'node_modules',
|
|
24
45
|
'package.json',
|
|
@@ -27,7 +48,10 @@ export const openCodeExcluded = new Set([
|
|
|
27
48
|
'.gitignore',
|
|
28
49
|
])
|
|
29
50
|
|
|
30
|
-
|
|
51
|
+
// Never add a project-owned file here (AGENTS.project.md, opencode.json,
|
|
52
|
+
// .prettierignore): those are scaffolded create-if-absent and must not ship
|
|
53
|
+
// from the package. opencode.json is governed solely by projectOwnedPaths.
|
|
54
|
+
export const rootFiles = ['AGENTS.md', 'skills-lock.json']
|
|
31
55
|
|
|
32
56
|
export const scriptsToSync = [
|
|
33
57
|
'memory-cli.js',
|
|
@@ -44,6 +68,153 @@ export const scriptsToSync = [
|
|
|
44
68
|
'knowledgebase-init.sql',
|
|
45
69
|
]
|
|
46
70
|
|
|
71
|
+
// The hash-managed paths a consumer's .prettierignore must cover so a format
|
|
72
|
+
// pass cannot freeze a sync. Directories carry a trailing slash so a pattern
|
|
73
|
+
// can cover their subtree. AGENTS.project.md is deliberately absent: it is
|
|
74
|
+
// project-owned, so formatting it cannot freeze a sync.
|
|
75
|
+
export const requiredPrettierIgnorePatterns = [
|
|
76
|
+
'.agents/',
|
|
77
|
+
'.opencode/agents/',
|
|
78
|
+
'.opencode/commands/',
|
|
79
|
+
...rootFiles,
|
|
80
|
+
...scriptsToSync.map((script) => `scripts/${script}`),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
const prettierIgnoreWarning =
|
|
84
|
+
'[ai-workflow-template] Existing .prettierignore does not protect managed files; merge the patterns from node_modules/@abarcenas/ai-workflow-template/.prettierignore'
|
|
85
|
+
|
|
86
|
+
const ignoreAllPatterns = new Set(['', '.', '*', '**', '/'])
|
|
87
|
+
|
|
88
|
+
function normalizeIgnorePattern(pattern) {
|
|
89
|
+
const trimmed = pattern.trim()
|
|
90
|
+
return trimmed === '/'
|
|
91
|
+
? '/'
|
|
92
|
+
: trimmed.replace(/^\/+/, '').replace(/\/+$/, '')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Gitignore lines, preserving negation and treating an inline "# ..." literally.
|
|
96
|
+
// Escaped leading "#" or "!" are literals, not comments/negations.
|
|
97
|
+
export function parsePrettierIgnoreRules(contents) {
|
|
98
|
+
const rules = []
|
|
99
|
+
|
|
100
|
+
for (const rawLine of contents.split(/\r?\n/)) {
|
|
101
|
+
const line = rawLine.trim()
|
|
102
|
+
if (!line || line.startsWith('#')) {
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (line.startsWith('\\#') || line.startsWith('\\!')) {
|
|
107
|
+
rules.push({
|
|
108
|
+
negated: false,
|
|
109
|
+
pattern: normalizeIgnorePattern(line.slice(1)),
|
|
110
|
+
})
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const negated = line.startsWith('!')
|
|
115
|
+
const pattern = normalizeIgnorePattern(negated ? line.slice(1) : line)
|
|
116
|
+
if (pattern) {
|
|
117
|
+
rules.push({ negated, pattern })
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return rules
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// True when a pattern ignores targetPath or one of its ancestors. A "base/**"
|
|
125
|
+
// pattern ignores strict descendants only, so it never covers the file "base".
|
|
126
|
+
function patternAppliesTo(pattern, targetPath) {
|
|
127
|
+
if (ignoreAllPatterns.has(pattern)) {
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
if (pattern.endsWith('/**')) {
|
|
131
|
+
return targetPath.startsWith(`${pattern.slice(0, -3)}/`)
|
|
132
|
+
}
|
|
133
|
+
if (pattern.startsWith('**/')) {
|
|
134
|
+
const suffix = pattern.slice(3)
|
|
135
|
+
return targetPath === suffix || targetPath.endsWith(`/${suffix}`)
|
|
136
|
+
}
|
|
137
|
+
if (/[*?[]/.test(pattern)) {
|
|
138
|
+
return false
|
|
139
|
+
}
|
|
140
|
+
return targetPath === pattern || targetPath.startsWith(`${pattern}/`)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// A negation below a managed directory punches a hole in that directory's
|
|
144
|
+
// subtree, so it removes coverage until a later positive restores the subtree.
|
|
145
|
+
function ignorePatternBase(pattern) {
|
|
146
|
+
return normalizeIgnorePattern(
|
|
147
|
+
pattern.endsWith('/**') ? pattern.slice(0, -3) : pattern,
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function patternAppliesBelow(pattern, directoryPath) {
|
|
152
|
+
if (ignoreAllPatterns.has(pattern)) {
|
|
153
|
+
return true
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const base = ignorePatternBase(pattern)
|
|
157
|
+
return (
|
|
158
|
+
!/[*?[]/.test(base) &&
|
|
159
|
+
base.startsWith(directoryPath) &&
|
|
160
|
+
base !== normalizeIgnorePattern(directoryPath)
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Last-match-wins over the rule list, matching gitignore semantics. Required
|
|
165
|
+
// directories also track negated subtrees so a later positive can restore them.
|
|
166
|
+
function isCovered(requiredPath, rules) {
|
|
167
|
+
const isDirectory = requiredPath.endsWith('/')
|
|
168
|
+
let covered = false
|
|
169
|
+
const holes = new Set()
|
|
170
|
+
|
|
171
|
+
for (const { negated, pattern } of rules) {
|
|
172
|
+
if (!isDirectory) {
|
|
173
|
+
if (patternAppliesTo(pattern, requiredPath)) {
|
|
174
|
+
covered = !negated
|
|
175
|
+
}
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (negated) {
|
|
180
|
+
if (patternAppliesTo(pattern, requiredPath)) {
|
|
181
|
+
covered = false
|
|
182
|
+
holes.clear()
|
|
183
|
+
} else if (patternAppliesBelow(pattern, requiredPath)) {
|
|
184
|
+
holes.add(ignorePatternBase(pattern))
|
|
185
|
+
}
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (patternAppliesTo(pattern, requiredPath)) {
|
|
190
|
+
covered = true
|
|
191
|
+
holes.clear()
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (patternAppliesBelow(pattern, requiredPath)) {
|
|
196
|
+
const base = ignorePatternBase(pattern)
|
|
197
|
+
for (const hole of holes) {
|
|
198
|
+
if (hole === base || hole.startsWith(`${base}/`)) {
|
|
199
|
+
holes.delete(hole)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return covered && holes.size === 0
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function missingPrettierIgnorePatterns(
|
|
209
|
+
contents,
|
|
210
|
+
requiredPatterns = requiredPrettierIgnorePatterns,
|
|
211
|
+
) {
|
|
212
|
+
const rules = parsePrettierIgnoreRules(contents)
|
|
213
|
+
return requiredPatterns.filter(
|
|
214
|
+
(requiredPath) => !isCovered(requiredPath, rules),
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
47
218
|
export const memoryBankStubs = {
|
|
48
219
|
'projectbrief.md': `# projectbrief
|
|
49
220
|
|
|
@@ -106,6 +277,49 @@ Cross-session knowledge accumulated by agent pipelines. Each session records dis
|
|
|
106
277
|
`,
|
|
107
278
|
}
|
|
108
279
|
|
|
280
|
+
// Project-owned files are scaffolded create-if-absent and then left alone for
|
|
281
|
+
// the lifetime of the consumer project (deleting one recreates it on the next
|
|
282
|
+
// sync). The .prettierignore text here must stay byte-identical to the
|
|
283
|
+
// repository's shipped .prettierignore (asserted in sync-unit.test.js) so the
|
|
284
|
+
// tarball copy and the scaffold cannot drift.
|
|
285
|
+
export const projectOwnedStubs = {
|
|
286
|
+
'AGENTS.project.md': `# Project Rules
|
|
287
|
+
|
|
288
|
+
> Project-owned. This file is never overwritten or pruned by sync — edit it freely.
|
|
289
|
+
|
|
290
|
+
## Project-specific agent rules
|
|
291
|
+
|
|
292
|
+
- {{add project-specific rules here}}
|
|
293
|
+
`,
|
|
294
|
+
'.prettierignore': `# Project-owned. Sync never overwrites this file.
|
|
295
|
+
# Keep formatters away from files managed by @abarcenas/ai-workflow-template:
|
|
296
|
+
# a formatting pass over managed files makes every future sync skip them.
|
|
297
|
+
|
|
298
|
+
# Managed agent configuration
|
|
299
|
+
.agents/
|
|
300
|
+
.opencode/agents/
|
|
301
|
+
.opencode/commands/
|
|
302
|
+
|
|
303
|
+
# Managed root files
|
|
304
|
+
AGENTS.md
|
|
305
|
+
AGENTS.project.md
|
|
306
|
+
skills-lock.json
|
|
307
|
+
|
|
308
|
+
# Managed scripts (scripts/sync.js scriptsToSync)
|
|
309
|
+
scripts/memory-cli.js
|
|
310
|
+
scripts/memory-index.js
|
|
311
|
+
scripts/bump-version.js
|
|
312
|
+
scripts/validate-memory-schema.js
|
|
313
|
+
scripts/vocab-sync.js
|
|
314
|
+
scripts/mcp-memory-server.js
|
|
315
|
+
scripts/mcp/playwright-mcp-launcher.js
|
|
316
|
+
scripts/knowledgebase-cli.js
|
|
317
|
+
scripts/knowledgebase-index.js
|
|
318
|
+
scripts/mcp-knowledgebase-server.js
|
|
319
|
+
scripts/knowledgebase-init.sql
|
|
320
|
+
`,
|
|
321
|
+
}
|
|
322
|
+
|
|
109
323
|
// Parses the flags supported by the sync entry point. Unknown flags are ignored
|
|
110
324
|
// so that consumer tooling can pass extra arguments without breaking a sync.
|
|
111
325
|
export function parseArgs(argv = [], env = process.env) {
|
|
@@ -263,10 +477,16 @@ export function isPathInside(baseDir, candidatePath) {
|
|
|
263
477
|
// Resolves a manifest key to its package path (for the "still shipped?" check)
|
|
264
478
|
// and its consumer path (the prune candidate). Returns null for keys that the
|
|
265
479
|
// sync layer does not own, and for paths that escape their channel root.
|
|
266
|
-
export function resolvePruneEntry(manifestKey, { pruneChannels
|
|
480
|
+
export function resolvePruneEntry(manifestKey, { pruneChannels }) {
|
|
267
481
|
let channel
|
|
268
482
|
let relativePath
|
|
269
483
|
|
|
484
|
+
// Scaffold-only namespace for project-owned files. There is no package source
|
|
485
|
+
// to compare against and nothing here may ever be deleted.
|
|
486
|
+
if (manifestKey.startsWith('__project__/')) {
|
|
487
|
+
return null
|
|
488
|
+
}
|
|
489
|
+
|
|
270
490
|
if (manifestKey.startsWith('__opencode__/')) {
|
|
271
491
|
channel = pruneChannels.opencode
|
|
272
492
|
relativePath = manifestKey.slice(channel.prefix.length)
|
|
@@ -289,22 +509,20 @@ export function resolvePruneEntry(manifestKey, { pruneChannels, packageRoot }) {
|
|
|
289
509
|
} else {
|
|
290
510
|
channel = pruneChannels.agents
|
|
291
511
|
relativePath = manifestKey
|
|
292
|
-
if (excludedRelativePaths.has(relativePath)) {
|
|
293
|
-
return null
|
|
294
|
-
}
|
|
295
512
|
}
|
|
296
513
|
|
|
297
514
|
if (!relativePath) {
|
|
298
515
|
return null
|
|
299
516
|
}
|
|
300
517
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
packagePaths.push(resolve(packageRoot, 'opencode.mcp.example.json'))
|
|
518
|
+
// Project-owned files survive every prune, even when the package no longer
|
|
519
|
+
// ships a same-named path. This is the single check for all four channels.
|
|
520
|
+
if (isProjectOwnedPath(relativePath)) {
|
|
521
|
+
return null
|
|
306
522
|
}
|
|
307
523
|
|
|
524
|
+
const packagePaths = [resolve(channel.packageDir, relativePath)]
|
|
525
|
+
|
|
308
526
|
const consumerPath = resolve(channel.consumerDir, relativePath)
|
|
309
527
|
if (!isPathInside(channel.packageDir, packagePaths[0])) {
|
|
310
528
|
return null
|
|
@@ -356,9 +574,11 @@ export function syncProject(context) {
|
|
|
356
574
|
|
|
357
575
|
/* ── Sync .agents/ directory ────────────────────────────────── */
|
|
358
576
|
|
|
577
|
+
// Project ownership outranks every flag: these paths are skipped before the
|
|
578
|
+
// --force branch below, so --force can never replace consumer-owned content.
|
|
359
579
|
for (const sourceFile of sourceFiles) {
|
|
360
580
|
const relativePath = relative(sourceAgentsDir, sourceFile)
|
|
361
|
-
if (
|
|
581
|
+
if (isProjectOwnedPath(relativePath)) {
|
|
362
582
|
continue
|
|
363
583
|
}
|
|
364
584
|
|
|
@@ -396,7 +616,11 @@ export function syncProject(context) {
|
|
|
396
616
|
|
|
397
617
|
for (const sourceFile of openCodeFiles) {
|
|
398
618
|
const relativePath = relative(sourceOpenCodeDir, sourceFile)
|
|
399
|
-
if (
|
|
619
|
+
if (
|
|
620
|
+
isProjectOwnedPath(relativePath) ||
|
|
621
|
+
openCodeExcluded.has(relativePath) ||
|
|
622
|
+
openCodeExcluded.has(relativePath.split('/')[0])
|
|
623
|
+
) {
|
|
400
624
|
continue
|
|
401
625
|
}
|
|
402
626
|
|
|
@@ -434,6 +658,10 @@ export function syncProject(context) {
|
|
|
434
658
|
let scriptsSkipped = 0
|
|
435
659
|
|
|
436
660
|
for (const scriptRelPath of scriptsToSync) {
|
|
661
|
+
if (isProjectOwnedPath(scriptRelPath)) {
|
|
662
|
+
continue
|
|
663
|
+
}
|
|
664
|
+
|
|
437
665
|
const sourceFile = resolve(sourceScriptsDir, scriptRelPath)
|
|
438
666
|
const targetFile = resolve(targetScriptsDir, scriptRelPath)
|
|
439
667
|
|
|
@@ -473,6 +701,12 @@ export function syncProject(context) {
|
|
|
473
701
|
/* ── Sync root files ────────────────────────────────────────── */
|
|
474
702
|
|
|
475
703
|
for (const rootFile of rootFiles) {
|
|
704
|
+
// Defense in depth: projectOwnedPaths wins if a project-owned name is ever
|
|
705
|
+
// added to rootFiles. opencode.json is no longer in this allowlist at all.
|
|
706
|
+
if (isProjectOwnedPath(rootFile)) {
|
|
707
|
+
continue
|
|
708
|
+
}
|
|
709
|
+
|
|
476
710
|
const sourceFile = resolve(packageRoot, rootFile)
|
|
477
711
|
if (!existsSync(sourceFile)) {
|
|
478
712
|
continue
|
|
@@ -542,11 +776,60 @@ export function syncProject(context) {
|
|
|
542
776
|
scaffolded += 1
|
|
543
777
|
}
|
|
544
778
|
|
|
779
|
+
/* ── Scaffold project-owned files ───────────────────────────── */
|
|
780
|
+
|
|
781
|
+
// Create-if-absent only. The __project__ namespace is reporting metadata
|
|
782
|
+
// written when a file is first created by sync; pre-existing consumer files
|
|
783
|
+
// are intentionally absent, because there is no managed baseline to report.
|
|
784
|
+
// It is never resolved by the copy or prune passes and never drives an
|
|
785
|
+
// overwrite. Not refreshing it on later runs keeps a consumer-owned file from
|
|
786
|
+
// producing manifest churn on every install.
|
|
787
|
+
const targetPrettierIgnore = resolve(consumerRoot, '.prettierignore')
|
|
788
|
+
const prettierIgnoreExisted = existsSync(targetPrettierIgnore)
|
|
789
|
+
|
|
790
|
+
for (const [fileName, fileContent] of Object.entries(projectOwnedStubs)) {
|
|
791
|
+
const targetFile = resolve(consumerRoot, fileName)
|
|
792
|
+
const trackedKey = `__project__/${fileName}`
|
|
793
|
+
let didScaffold = false
|
|
794
|
+
|
|
795
|
+
if (!existsSync(targetFile)) {
|
|
796
|
+
ensureParentDirectory(targetFile)
|
|
797
|
+
if (isVerbose) console.error(` … scaffolding: ${fileName}`)
|
|
798
|
+
writeFileSync(targetFile, fileContent, 'utf8')
|
|
799
|
+
scaffolded += 1
|
|
800
|
+
didScaffold = true
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Only hash what this run created, and only if it is a regular file: a
|
|
804
|
+
// directory or unreadable path at this location must not abort the run.
|
|
805
|
+
if (didScaffold && isRegularFile(targetFile)) {
|
|
806
|
+
manifest.files[trackedKey] = hashFile(targetFile)
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// An existing .prettierignore is project-owned and is never edited. Tell the
|
|
811
|
+
// consumer when it leaves managed files exposed, once and deterministically.
|
|
812
|
+
if (prettierIgnoreExisted) {
|
|
813
|
+
try {
|
|
814
|
+
const missingPatterns = missingPrettierIgnorePatterns(
|
|
815
|
+
readFileSync(targetPrettierIgnore, 'utf8'),
|
|
816
|
+
)
|
|
817
|
+
if (missingPatterns.length > 0) {
|
|
818
|
+
console.warn(prettierIgnoreWarning)
|
|
819
|
+
}
|
|
820
|
+
} catch {
|
|
821
|
+
// A directory or unreadable path at .prettierignore is project-owned
|
|
822
|
+
// state we cannot inspect. Skip the advisory rather than abort the run.
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
545
826
|
/* ── Auto-copy opencode.mcp.example.json → opencode.json ────── */
|
|
546
827
|
|
|
828
|
+
// Existing consumer configs are never rewritten. The example carries the
|
|
829
|
+
// `instructions` entry that loads AGENTS.project.md for fresh consumers.
|
|
547
830
|
const exampleMcpFile = resolve(packageRoot, 'opencode.mcp.example.json')
|
|
548
831
|
const targetMcpFile = resolve(consumerRoot, 'opencode.json')
|
|
549
|
-
const mcpTrackedKey = '
|
|
832
|
+
const mcpTrackedKey = '__project__/opencode.json'
|
|
550
833
|
|
|
551
834
|
if (existsSync(exampleMcpFile) && !existsSync(targetMcpFile)) {
|
|
552
835
|
if (isVerbose) console.error(' … scaffolding: opencode.json from example')
|