Skip to content

Commit da38c28

Browse files
elberrdclaude
andcommitted
fix: discard condicional de templateOwnedPaths — harness vira fonte única
O discard incondicional apagava os 4 skills profissionais da cópia do harness em modo full mesmo quando o template não os trazia — projetos gerados saíam SEM os skills no .cursor (templates nunca tiveram .cursor/) e com symlinks .agents quebrados; no .claude recebiam a cópia PT defasada do template em vez da EN atual do harness. Agora o caminho só é descartado quando o template instalado realmente o trouxe (helper puro ownedPathsToDiscard + 6 testes herméticos). Efeitos: o buraco do .cursor fecha; live1 (sem nenhum dos 4) herda tudo do harness; live2 vence apenas em security (variante multi-tenant, também em .cursor/skills). DOCS §8 e README atualizados para a semântica nova. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5b6e588 commit da38c28

5 files changed

Lines changed: 115 additions & 26 deletions

File tree

DOCS.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -351,13 +351,16 @@ design-system, security, backend-profissional) and `ai-docs/` (PRD, maps,
351351
task roadmap) for Claude Code and Cursor.
352352

353353
Skills shared with the templates (`HARNESS.templateOwnedPaths` — the four
354-
professional ones, in `.claude/skills` and `.cursor/skills`): in `full` mode
355-
the harness copy is DISCARDED before the merge — the template already ships
356-
them and is the canonical source of the generated project (live2's
357-
`security`, for example, is the multi-tenant variant). In `harness` mode
358-
(no template) they come from the harness (copies of the generic variant,
359-
live1's) — whoever installs only the harness also gets the full professional
360-
standard. The `asaas` skill belongs to an addon and lives only in the templates.
354+
professional ones, in `.claude/skills` and `.cursor/skills`): the harness is
355+
the SINGLE SOURCE of this material. In `full` mode the harness copy of a path
356+
is discarded only when the installed template actually shipped that path
357+
(the merge runs after the template install, so the destination already
358+
reflects it) — a template that brings its own variant wins in that path
359+
(live2 ships only `security`, its multi-tenant variant), and a template
360+
without the skill inherits the harness version (live1 no longer ships any of
361+
the four; templates without `.cursor/` still get the four skills in Cursor
362+
from the harness). In `harness` mode (no template) everything comes from the
363+
harness. The `asaas` skill belongs to an addon and lives only in the templates.
361364

362365
In `full` mode it runs last (the git repo already exists, created during the
363366
template install); in `harness` mode the folder may have no git — the step

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -564,10 +564,11 @@ summary) — every template-specific step is skipped.
564564
adapts to whatever is already there instead of clobbering it: existing
565565
files are never overwritten, the harness `README.md` lands as
566566
`HARNESS.md`, and its `AGENTS.md` is appended to the project's `AGENTS.md`
567-
between `<!-- harness-start/end -->` markers. Skills the templates also
568-
ship (the professional set) are dropped from the harness copy in full
569-
mode — the template's version is canonical; harness-only installs get
570-
them from the harness. Makes
567+
between `<!-- harness-start/end -->` markers. The harness is the single
568+
source of the professional skill set; when the installed template ships
569+
its own variant of one of those paths (e.g. live2's multi-tenant
570+
`security`), the harness copy of that path is dropped and the template's
571+
wins — every path the template did not ship comes from the harness. Makes
571572
its own commit (initializing a git repo first if the folder has none) and
572573
pushes when a remote was created in step 19.
573574
22. **Stack manifest + docs** — writes `ai-docs/stack.md` (the manifest the

src/config.js

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,14 @@ export const HARNESS = {
8585
readmeAs: 'HARNESS.md',
8686
markerStart: '<!-- harness-start -->',
8787
markerEnd: '<!-- harness-end -->',
88-
// Skills that exist in the harness AND in the templates (live1/live2). In
89-
// full mode the template already ships them — the harness merge drops its
90-
// own copy to avoid assembling a mixed version (the per-file cp could add
91-
// files that only exist in the harness version). In harness-only mode they
92-
// are installed normally — that's what guarantees the skills for people who
93-
// do NOT use a template.
94-
// (In the harness the copies come from live1, the generic variant; the
95-
// asaas skill belongs to an addon and lives only in the templates.)
88+
// Paths a template MAY own. The harness is the single source of the agent
89+
// material; a harness copy is only discarded (full mode) when the installed
90+
// template actually shipped that exact path — the template's variant wins
91+
// THERE, and the harness merge won't assemble a mixed version (the per-file
92+
// cp could add files that only exist in the harness variant). Every path
93+
// the template did not ship — and everything in harness-only mode — comes
94+
// from the harness.
95+
// (The asaas skill belongs to an addon and lives only in the templates.)
9696
templateOwnedPaths: ['frontend-profissional', 'design-system', 'security', 'backend-profissional'].flatMap(
9797
(skill) => [`.claude/skills/${skill}`, `.cursor/skills/${skill}`],
9898
),

src/steps/harness.js

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ import * as ui from '../lib/ui.js';
2424
* - the harness README.md lands as HARNESS.md — the project README wins;
2525
* - the harness AGENTS.md is APPENDED to the project's AGENTS.md between
2626
* harness markers (idempotent: markers present ⇒ nothing to do);
27-
* - skills that also exist in the templates (HARNESS.templateOwnedPaths,
28-
* e.g. frontend-profissional) are discarded in full mode — the template
29-
* is the canonical source; in harness-only mode they are installed.
27+
* - skills a template MAY also ship (HARNESS.templateOwnedPaths, e.g.
28+
* frontend-profissional) are discarded in full mode ONLY when the
29+
* installed template actually brought that path — the template's variant
30+
* wins there; every path the template did not ship (and everything in
31+
* harness-only mode) comes from the harness, the single source.
3032
*
3133
* In 'full' mode it runs last (after GitHub/Vercel) and makes its own
3234
* best-effort commit. In 'harness' mode the folder may not have a git repo
@@ -86,11 +88,15 @@ export async function setupHarness(ctx) {
8688
harnessAgents = await readFile(join(tmpClone, 'AGENTS.md'), 'utf8');
8789
await rm(join(tmpClone, 'AGENTS.md'), { force: true });
8890
}
89-
// Skills shared with the templates (e.g. frontend-profissional):
90-
// in full mode the TEMPLATE's copy is the canonical one and is already
91-
// installed — we discard the harness copy so the merge doesn't mix versions.
91+
// Skills a template MAY also ship (e.g. frontend-profissional): in full
92+
// mode the merge runs AFTER installTemplate, so the destination already
93+
// reflects the template — for each shared path that the template actually
94+
// brought, discard the harness copy so the merge doesn't mix versions.
95+
// Paths the template did NOT ship stay in the clone: the harness is the
96+
// single source for them (e.g. templates without .cursor/ still get the
97+
// four professional skills in Cursor).
9298
if (!harnessOnly) {
93-
for (const rel of HARNESS.templateOwnedPaths ?? []) {
99+
for (const rel of ownedPathsToDiscard(HARNESS.templateOwnedPaths, dir)) {
94100
await rm(join(tmpClone, rel), { recursive: true, force: true });
95101
}
96102
}
@@ -163,6 +169,20 @@ export async function setupHarness(ctx) {
163169
);
164170
}
165171

172+
/**
173+
* Which of the template-ownable paths should be dropped from the harness clone
174+
* before the merge: exactly those the installed template actually shipped
175+
* (i.e. that already exist in the destination). Pure decision — the fs check
176+
* is injectable for tests.
177+
* @param {string[] | undefined} templateOwnedPaths relative paths a template MAY own
178+
* @param {string} destDir project directory (already reflects the template)
179+
* @param {(path: string) => boolean} [exists] defaults to fs.existsSync
180+
* @returns {string[]} relative paths to discard from the harness copy
181+
*/
182+
export function ownedPathsToDiscard(templateOwnedPaths, destDir, exists = existsSync) {
183+
return (templateOwnedPaths ?? []).filter((rel) => exists(join(destDir, rel)));
184+
}
185+
166186
/**
167187
* Append the harness agent instructions to the project's AGENTS.md between
168188
* markers. Idempotent: if the start marker is already there, do nothing.

test/harness-merge.test.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { test } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { mkdtempSync, mkdirSync } from 'node:fs';
4+
import { join } from 'node:path';
5+
import { tmpdir } from 'node:os';
6+
import { ownedPathsToDiscard } from '../src/steps/harness.js';
7+
import { HARNESS } from '../src/config.js';
8+
9+
const SKILLS = ['frontend-profissional', 'design-system', 'security', 'backend-profissional'];
10+
11+
function tmpProject() {
12+
return mkdtempSync(join(tmpdir(), 'harness-merge-'));
13+
}
14+
15+
test('template without .cursor/: no .cursor/skills/* path is discarded', () => {
16+
const dest = tmpProject();
17+
// Template shipped only the Claude side of the four skills.
18+
for (const skill of SKILLS) {
19+
mkdirSync(join(dest, '.claude', 'skills', skill), { recursive: true });
20+
}
21+
const discard = ownedPathsToDiscard(HARNESS.templateOwnedPaths, dest);
22+
assert.deepEqual(
23+
discard.sort(),
24+
SKILLS.map((s) => `.claude/skills/${s}`).sort(),
25+
);
26+
assert.ok(!discard.some((rel) => rel.startsWith('.cursor/')), 'harness must still cover Cursor');
27+
});
28+
29+
test('template with only .claude/skills/security: only that path is discarded', () => {
30+
const dest = tmpProject();
31+
mkdirSync(join(dest, '.claude', 'skills', 'security'), { recursive: true });
32+
assert.deepEqual(ownedPathsToDiscard(HARNESS.templateOwnedPaths, dest), ['.claude/skills/security']);
33+
});
34+
35+
test('template with all shared paths: the 8 are discarded', () => {
36+
const dest = tmpProject();
37+
for (const rel of HARNESS.templateOwnedPaths) {
38+
mkdirSync(join(dest, rel), { recursive: true });
39+
}
40+
const discard = ownedPathsToDiscard(HARNESS.templateOwnedPaths, dest);
41+
assert.equal(discard.length, 8);
42+
assert.deepEqual(discard.sort(), [...HARNESS.templateOwnedPaths].sort());
43+
});
44+
45+
test('nonexistent destDir: nothing is discarded', () => {
46+
const dest = join(tmpProject(), 'does-not-exist');
47+
assert.deepEqual(ownedPathsToDiscard(HARNESS.templateOwnedPaths, dest), []);
48+
});
49+
50+
test('empty/undefined templateOwnedPaths: nothing is discarded', () => {
51+
const dest = tmpProject();
52+
assert.deepEqual(ownedPathsToDiscard(undefined, dest), []);
53+
assert.deepEqual(ownedPathsToDiscard([], dest), []);
54+
});
55+
56+
test('injectable exists: decision is pure and follows the predicate', () => {
57+
const seen = [];
58+
const exists = (p) => {
59+
seen.push(p);
60+
return p.endsWith(join('.cursor', 'skills', 'design-system'));
61+
};
62+
const discard = ownedPathsToDiscard(HARNESS.templateOwnedPaths, '/virtual/project', exists);
63+
assert.deepEqual(discard, ['.cursor/skills/design-system']);
64+
assert.equal(seen.length, HARNESS.templateOwnedPaths.length);
65+
});

0 commit comments

Comments
 (0)