@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.
@@ -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.
@@ -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/`
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "$schema": "https://opencode.ai/config.json",
3
+ "instructions": ["AGENTS.project.md"],
3
4
  "mcp": {
4
5
  "chrome-devtools": {
5
6
  "type": "local",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abarcenas/ai-workflow-template",
3
- "version": "1.46.0",
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('keeps opencode.json alive via the example fallback source', () => {
601
+ it('never resolves project-owned files as prune entries', () => {
592
602
  const ctx = context()
593
603
 
594
- const entry = resolvePruneEntry('__root__/opencode.json', ctx)
595
-
596
- expect(entry.packagePaths).toEqual([
597
- join(ctx.packageRoot, 'opencode.json'),
598
- join(ctx.packageRoot, 'opencode.mcp.example.json'),
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(8)
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['__root__/opencode.json']).toBe(sha256(MCP_EXAMPLE))
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('prefers a package-shipped opencode.json over the example file', () => {
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
- '{ "mcp": { "from": "package" } }\n',
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
- export const rootFiles = ['AGENTS.md', 'opencode.json', 'skills-lock.json']
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, packageRoot }) {
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
- const packagePaths = [resolve(channel.packageDir, relativePath)]
302
- if (channel === pruneChannels.root && relativePath === 'opencode.json') {
303
- // opencode.json is scaffolded from the example when the package does not ship
304
- // it directly (auto-copy pass above). Either source keeps the file alive.
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 (excludedRelativePaths.has(relativePath)) {
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 (openCodeExcluded.has(relativePath) || openCodeExcluded.has(relativePath.split('/')[0])) {
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 = '__root__/opencode.json'
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')