@trycore/spec-build-harness 0.1.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/.claude-plugin/marketplace.json +21 -0
- package/.claude-plugin/plugin.json +28 -0
- package/GOVERNANCE.md +48 -0
- package/INSTALL.md +295 -0
- package/METODOLOGIA.md +360 -0
- package/README.md +130 -0
- package/VERSION +1 -0
- package/agents/build/api-contract-tester.md +32 -0
- package/agents/build/build-orchestrator.md +50 -0
- package/agents/build/change-epic-coherence.md +41 -0
- package/agents/build/coherence-three-way.md +35 -0
- package/agents/build/data-consistency-checker.md +48 -0
- package/agents/build/dor-dod-gatekeeper.md +46 -0
- package/agents/build/security-reviewer.md +46 -0
- package/agents/build/simple-design-reviewer.md +33 -0
- package/agents/build/stack-guardian.md +41 -0
- package/agents/build/ux-krug-reviewer.md +33 -0
- package/commands/build/onboard.md +136 -0
- package/commands/opsx/apply.md +152 -0
- package/commands/opsx/archive.md +157 -0
- package/commands/opsx/bulk-archive.md +242 -0
- package/commands/opsx/continue.md +114 -0
- package/commands/opsx/explore.md +174 -0
- package/commands/opsx/ff.md +94 -0
- package/commands/opsx/new.md +69 -0
- package/commands/opsx/onboard.md +525 -0
- package/commands/opsx/sync.md +134 -0
- package/commands/opsx/verify.md +164 -0
- package/config/stack-allowlist.template.json +12 -0
- package/dist/cli.js +105 -0
- package/dist/commands/doctor.js +77 -0
- package/dist/commands/init.js +129 -0
- package/dist/commands/status.js +59 -0
- package/dist/commands/uninstall.js +52 -0
- package/dist/commands/update.js +11 -0
- package/dist/lib/install-engine.js +99 -0
- package/dist/lib/markers.js +81 -0
- package/dist/lib/paths.js +65 -0
- package/dist/lib/settings-merge.js +125 -0
- package/dist/lib/stack-prompt.js +69 -0
- package/dist/lib/state-seed.js +59 -0
- package/docs/agents.md +133 -0
- package/docs/commands.md +128 -0
- package/docs/customization/mcp-extensions.md +117 -0
- package/docs/examples/reference/data-consistency.example.md +61 -0
- package/docs/examples/reference/security-foco.example.md +39 -0
- package/docs/examples/reference/stack-allowlist.example.json +61 -0
- package/docs/getting-started.md +260 -0
- package/docs/hooks.md +143 -0
- package/hooks/build/build-gate-check.sh +24 -0
- package/hooks/build/coherence-flag.sh +23 -0
- package/hooks/build/gitflow-guard.sh +65 -0
- package/hooks/build/lint-typecheck.sh +33 -0
- package/hooks/build/load-build-state.sh +46 -0
- package/hooks/build/stack-guard.sh +63 -0
- package/hooks/build-harness.json +61 -0
- package/internal/skills/auditar-arnes/SKILL.md +29 -0
- package/package.json +67 -0
- package/scripts/check-agnostic.sh +81 -0
- package/scripts/check-state-clean.sh +48 -0
- package/scripts/check-version-sync.sh +51 -0
- package/scripts/denylist.txt +30 -0
- package/skills/building-a-slice/SKILL.md +82 -0
- package/skills/building-a-slice/references/data-consistency.md +34 -0
- package/skills/building-a-slice/references/dod.md +25 -0
- package/skills/building-a-slice/references/dor.md +17 -0
- package/skills/building-a-slice/references/gitflow.md +30 -0
- package/skills/building-a-slice/references/krug-ux.md +27 -0
- package/skills/building-a-slice/references/link-change-epic.md +34 -0
- package/skills/building-a-slice/references/mcp-map.md +29 -0
- package/skills/building-a-slice/references/newman-tests.md +47 -0
- package/skills/building-a-slice/references/simple-design.md +33 -0
- package/skills/building-a-slice/references/state-protocol.md +48 -0
- package/skills/openspec-apply-change/SKILL.md +156 -0
- package/skills/openspec-archive-change/SKILL.md +114 -0
- package/skills/openspec-bulk-archive-change/SKILL.md +246 -0
- package/skills/openspec-continue-change/SKILL.md +118 -0
- package/skills/openspec-explore/SKILL.md +290 -0
- package/skills/openspec-ff-change/SKILL.md +101 -0
- package/skills/openspec-new-change/SKILL.md +74 -0
- package/skills/openspec-onboard/SKILL.md +529 -0
- package/skills/openspec-sync-specs/SKILL.md +138 -0
- package/skills/openspec-verify-change/SKILL.md +168 -0
- package/skills/releasing-a-version/SKILL.md +56 -0
- package/skills/releasing-a-version/references/release-dod.md +20 -0
- package/state/README.md +56 -0
- package/state/build-state.schema.json +111 -0
- package/state/build-state.template.json +7 -0
- package/templates/CLAUDE.md.template +57 -0
- package/templates/newman.collection.template.json +28 -0
- package/templates/settings-hooks.template.json +25 -0
- package/templates/waivers/WAIVER.template.md +27 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// commands/status.ts — estado de instalación + fase del arnés.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { readPackageVersion, targetPaths, PACKAGE_ROOT } from '../lib/paths.js';
|
|
5
|
+
import { countChildren } from '../lib/install-engine.js';
|
|
6
|
+
import { hasBinary } from './doctor.js';
|
|
7
|
+
export async function status(opts) {
|
|
8
|
+
const targetDir = path.resolve(opts.targetDir);
|
|
9
|
+
const t = targetPaths(targetDir);
|
|
10
|
+
const pkgVersion = readPackageVersion();
|
|
11
|
+
console.log('════════ Trycore spec-build-harness — estado ════════');
|
|
12
|
+
console.log(`Proyecto: ${targetDir}`);
|
|
13
|
+
console.log(`Paquete CLI: v${pkgVersion} (${PACKAGE_ROOT})`);
|
|
14
|
+
if (fs.existsSync(t.versionFile)) {
|
|
15
|
+
const installed = fs.readFileSync(t.versionFile, 'utf8').trim();
|
|
16
|
+
const drift = installed === pkgVersion ? '' : ' ⚠ desincronizado — corre `trycore-build update`';
|
|
17
|
+
console.log(`Instalado: v${installed}${drift}`);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
console.log('Instalado: — (corre `trycore-build init`)');
|
|
21
|
+
console.log('══════════════════════════════════════════════════════');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
console.log('');
|
|
25
|
+
console.log('Componentes:');
|
|
26
|
+
console.log(` Agentes: ${countChildren(t.agentsBuild)} en .claude/agents/build/`);
|
|
27
|
+
console.log(` Comandos: ${countChildren(t.commandsOpsx)} /opsx:* · ${countChildren(t.commandsBuild)} /build:*`);
|
|
28
|
+
console.log(` Skills: ${countChildren(t.skillsDir)} en .claude/skills/`);
|
|
29
|
+
console.log(` Hooks: ${countChildren(t.hooksBuild)} en .claude/hooks/build/`);
|
|
30
|
+
console.log('');
|
|
31
|
+
console.log('Requisitos externos:');
|
|
32
|
+
console.log(` openspec: ${hasBinary('openspec') ? '✓' : '✗ falta (npm i -g @fission-ai/openspec)'}`);
|
|
33
|
+
console.log(` python3: ${hasBinary('python3') ? '✓' : '✗ falta'}`);
|
|
34
|
+
// Estado del arnés
|
|
35
|
+
if (fs.existsSync(t.stateFile)) {
|
|
36
|
+
try {
|
|
37
|
+
const st = JSON.parse(fs.readFileSync(t.stateFile, 'utf8'));
|
|
38
|
+
console.log('');
|
|
39
|
+
console.log('Estado del arnés:');
|
|
40
|
+
console.log(` Fase: ${st.harness_phase ?? '?'}`);
|
|
41
|
+
const slice = st.active_slice;
|
|
42
|
+
if (slice) {
|
|
43
|
+
const openGates = Object.entries(slice.gates ?? {})
|
|
44
|
+
.filter(([, v]) => v === false || v === null)
|
|
45
|
+
.map(([k]) => k);
|
|
46
|
+
console.log(` Slice activo: ${slice.epica ?? '?'} (fase ${slice.phase ?? '?'})`);
|
|
47
|
+
console.log(` Gates abiertos: ${openGates.length ? openGates.join(', ') : 'ninguno'}`);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
console.log(' Slice activo: ninguno');
|
|
51
|
+
}
|
|
52
|
+
console.log(` Historial: ${(st.history ?? []).length} slice(s) · Releases: ${(st.releases ?? []).length}`);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
console.log(' ⚠ build-state.json malformado');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
console.log('══════════════════════════════════════════════════════');
|
|
59
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// commands/uninstall.ts — quita SOLO lo que init puso. Preserva state/ y config/
|
|
2
|
+
// (estado vivo y allowlist son del consumidor), igual que la referencia preserva docs/.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { ASSETS, targetPaths } from '../lib/paths.js';
|
|
6
|
+
import { removeMarkedBlock } from '../lib/markers.js';
|
|
7
|
+
import { removeHarnessSettings } from '../lib/settings-merge.js';
|
|
8
|
+
export async function uninstall(opts) {
|
|
9
|
+
const targetDir = path.resolve(opts.targetDir);
|
|
10
|
+
const t = targetPaths(targetDir);
|
|
11
|
+
if (!fs.existsSync(t.versionFile)) {
|
|
12
|
+
console.error(`⚠ No se detecta instalación de trycore-build-harness en ${targetDir}`);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
console.log(`▶ uninstall trycore-build-harness de ${targetDir}`);
|
|
16
|
+
// 1) Assets exclusivos del arnés
|
|
17
|
+
for (const p of [t.agentsBuild, t.commandsOpsx, t.commandsBuild, t.hooksBuild]) {
|
|
18
|
+
if (fs.existsSync(p))
|
|
19
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
// Skills del arnés (NO toda la carpeta skills/, que es compartida con spec-product-flow)
|
|
22
|
+
const skillNames = fs.existsSync(ASSETS.skills)
|
|
23
|
+
? fs.readdirSync(ASSETS.skills, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
|
|
24
|
+
: [];
|
|
25
|
+
for (const s of skillNames) {
|
|
26
|
+
const p = path.join(t.skillsDir, s);
|
|
27
|
+
if (fs.existsSync(p) || isSymlink(p))
|
|
28
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
// 2) Marca de versión
|
|
31
|
+
if (fs.existsSync(t.versionFile))
|
|
32
|
+
fs.rmSync(t.versionFile, { force: true });
|
|
33
|
+
// 3) Bloques marcados
|
|
34
|
+
removeMarkedBlock(t.claudeMd, BEGIN, END);
|
|
35
|
+
removeMarkedBlock(t.gitignore, GI_BEGIN, GI_END);
|
|
36
|
+
// 4) Hooks + permisos del arnés en settings.json (por cadena exacta)
|
|
37
|
+
removeHarnessSettings(t.settingsFile);
|
|
38
|
+
// 5) state/ y config/ NO se tocan — son del consumidor
|
|
39
|
+
console.log('✓ Desinstalado. Se conservaron .claude/state/ y .claude/config/ (estado y allowlist del equipo).');
|
|
40
|
+
}
|
|
41
|
+
const BEGIN = '<!-- BEGIN trycore-build-harness ';
|
|
42
|
+
const END = '<!-- END trycore-build-harness -->';
|
|
43
|
+
const GI_BEGIN = '# ── trycore-build-harness (auto)';
|
|
44
|
+
const GI_END = '# ── /trycore-build-harness';
|
|
45
|
+
function isSymlink(p) {
|
|
46
|
+
try {
|
|
47
|
+
return fs.lstatSync(p).isSymbolicLink();
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// commands/update.ts — refresca la instalación. Alias de `init --update` (no interactivo).
|
|
2
|
+
// Nunca pisa build-state.json ni stack-allowlist.json (el estado/config son del consumidor).
|
|
3
|
+
import { init } from './init.js';
|
|
4
|
+
export async function update(opts) {
|
|
5
|
+
await init({
|
|
6
|
+
targetDir: opts.targetDir,
|
|
7
|
+
mode: 'update',
|
|
8
|
+
useCopy: opts.useCopy,
|
|
9
|
+
skipDoctor: opts.skipDoctor,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// install-engine.ts — núcleo de instalación: link/copy de assets a .claude/.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Asegura el bit ejecutable (0755) en un archivo o en todos los archivos de un dir.
|
|
6
|
+
* Necesario tras `copy` porque fs.copyFileSync no preserva el modo de forma fiable
|
|
7
|
+
* cross-platform — los hooks bash fallarían silenciosamente sin +x.
|
|
8
|
+
*/
|
|
9
|
+
export function chmodExec(target) {
|
|
10
|
+
if (!fs.existsSync(target))
|
|
11
|
+
return;
|
|
12
|
+
const stat = fs.statSync(target);
|
|
13
|
+
if (stat.isDirectory()) {
|
|
14
|
+
for (const entry of fs.readdirSync(target)) {
|
|
15
|
+
chmodExec(path.join(target, entry));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
fs.chmodSync(target, 0o755);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Garantiza que un directorio exista.
|
|
24
|
+
*/
|
|
25
|
+
export function ensureDir(dir) {
|
|
26
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Crea symlink (o copia) desde src a dst.
|
|
30
|
+
* Si dst ya existe (file, dir o symlink), lo reemplaza.
|
|
31
|
+
*/
|
|
32
|
+
export function linkOrCopy(src, dst, mode) {
|
|
33
|
+
ensureDir(path.dirname(dst));
|
|
34
|
+
// Limpiar destino existente
|
|
35
|
+
if (fs.existsSync(dst) || isSymlink(dst)) {
|
|
36
|
+
fs.rmSync(dst, { recursive: true, force: true });
|
|
37
|
+
}
|
|
38
|
+
if (mode === 'symlink') {
|
|
39
|
+
// Symlink absoluto (más portable que relativo, especialmente entre máquinas con el mismo paquete global)
|
|
40
|
+
fs.symlinkSync(src, dst, fs.statSync(src).isDirectory() ? 'dir' : 'file');
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
if (fs.statSync(src).isDirectory()) {
|
|
44
|
+
copyDirRecursive(src, dst);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
fs.copyFileSync(src, dst);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function isSymlink(p) {
|
|
52
|
+
try {
|
|
53
|
+
return fs.lstatSync(p).isSymbolicLink();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function copyDirRecursive(src, dst) {
|
|
60
|
+
ensureDir(dst);
|
|
61
|
+
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
const srcPath = path.join(src, entry.name);
|
|
64
|
+
const dstPath = path.join(dst, entry.name);
|
|
65
|
+
if (entry.isDirectory()) {
|
|
66
|
+
copyDirRecursive(srcPath, dstPath);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
fs.copyFileSync(srcPath, dstPath);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Para cada subdirectorio en `srcRoot`, instala bajo `dstParent/<name>` (symlink o copy).
|
|
75
|
+
* Útil para skills/ donde cada subcarpeta es una skill.
|
|
76
|
+
*/
|
|
77
|
+
export function linkChildren(srcRoot, dstParent, mode, filter = () => true) {
|
|
78
|
+
if (!fs.existsSync(srcRoot))
|
|
79
|
+
return 0;
|
|
80
|
+
ensureDir(dstParent);
|
|
81
|
+
let count = 0;
|
|
82
|
+
for (const entry of fs.readdirSync(srcRoot, { withFileTypes: true })) {
|
|
83
|
+
if (!filter(entry.name))
|
|
84
|
+
continue;
|
|
85
|
+
const src = path.join(srcRoot, entry.name);
|
|
86
|
+
const dst = path.join(dstParent, entry.name);
|
|
87
|
+
linkOrCopy(src, dst, mode);
|
|
88
|
+
count++;
|
|
89
|
+
}
|
|
90
|
+
return count;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Cuenta cuántos hijos visibles tiene un directorio (para reportes).
|
|
94
|
+
*/
|
|
95
|
+
export function countChildren(dir) {
|
|
96
|
+
if (!fs.existsSync(dir))
|
|
97
|
+
return 0;
|
|
98
|
+
return fs.readdirSync(dir).filter((n) => !n.startsWith('.')).length;
|
|
99
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// markers.ts — inserta/reemplaza bloques marcados dentro de archivos de texto.
|
|
2
|
+
// Usado para CLAUDE.md y .gitignore. Idempotente.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
/**
|
|
5
|
+
* Si el archivo ya contiene el bloque (detectado por beginMarker), lo reemplaza.
|
|
6
|
+
* Si no, anexa el bloque al final del archivo (con una línea en blanco antes si tenía contenido).
|
|
7
|
+
* Si el archivo no existe, lo crea con solo el blockContent.
|
|
8
|
+
*/
|
|
9
|
+
export function upsertMarkedBlock(opts) {
|
|
10
|
+
const { beginMarker, endMarker, blockContent, filePath } = opts;
|
|
11
|
+
if (!fs.existsSync(filePath)) {
|
|
12
|
+
fs.writeFileSync(filePath, blockContent + '\n', 'utf8');
|
|
13
|
+
return 'created';
|
|
14
|
+
}
|
|
15
|
+
const existing = fs.readFileSync(filePath, 'utf8');
|
|
16
|
+
const lines = existing.split('\n');
|
|
17
|
+
const beginIdx = lines.findIndex((l) => l.includes(beginMarker));
|
|
18
|
+
if (beginIdx === -1) {
|
|
19
|
+
// No existe el bloque: anexar al final
|
|
20
|
+
const sep = existing.endsWith('\n') ? '' : '\n';
|
|
21
|
+
const prefix = existing.length > 0 ? '\n' : '';
|
|
22
|
+
fs.writeFileSync(filePath, existing + sep + prefix + blockContent + '\n', 'utf8');
|
|
23
|
+
return 'appended';
|
|
24
|
+
}
|
|
25
|
+
// Buscar la línea de fin desde beginIdx
|
|
26
|
+
let endIdx = -1;
|
|
27
|
+
for (let i = beginIdx; i < lines.length; i++) {
|
|
28
|
+
if (lines[i].includes(endMarker)) {
|
|
29
|
+
endIdx = i;
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (endIdx === -1) {
|
|
34
|
+
// Hay begin pero no end — archivo corrupto. Anexar al final como recovery.
|
|
35
|
+
const sep = existing.endsWith('\n') ? '' : '\n';
|
|
36
|
+
fs.writeFileSync(filePath, existing + sep + '\n' + blockContent + '\n', 'utf8');
|
|
37
|
+
return 'appended';
|
|
38
|
+
}
|
|
39
|
+
const before = lines.slice(0, beginIdx).join('\n');
|
|
40
|
+
const after = lines.slice(endIdx + 1).join('\n');
|
|
41
|
+
const blockLines = blockContent.split('\n');
|
|
42
|
+
const merged = [
|
|
43
|
+
...(before.length > 0 ? [before] : []),
|
|
44
|
+
...blockLines,
|
|
45
|
+
...(after.length > 0 ? [after] : []),
|
|
46
|
+
].join('\n');
|
|
47
|
+
fs.writeFileSync(filePath, merged.endsWith('\n') ? merged : merged + '\n', 'utf8');
|
|
48
|
+
return 'replaced';
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Elimina un bloque marcado (begin..end inclusive). Si no existe, no hace nada.
|
|
52
|
+
* Devuelve true si removió algo.
|
|
53
|
+
*/
|
|
54
|
+
export function removeMarkedBlock(filePath, beginMarker, endMarker) {
|
|
55
|
+
if (!fs.existsSync(filePath))
|
|
56
|
+
return false;
|
|
57
|
+
const existing = fs.readFileSync(filePath, 'utf8');
|
|
58
|
+
const lines = existing.split('\n');
|
|
59
|
+
const beginIdx = lines.findIndex((l) => l.includes(beginMarker));
|
|
60
|
+
if (beginIdx === -1)
|
|
61
|
+
return false;
|
|
62
|
+
let endIdx = -1;
|
|
63
|
+
for (let i = beginIdx; i < lines.length; i++) {
|
|
64
|
+
if (lines[i].includes(endMarker)) {
|
|
65
|
+
endIdx = i;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (endIdx === -1)
|
|
70
|
+
return false;
|
|
71
|
+
// Remover beginIdx..endIdx, y limpiar 1 línea en blanco antes/después si la hay
|
|
72
|
+
let removeStart = beginIdx;
|
|
73
|
+
let removeEnd = endIdx + 1;
|
|
74
|
+
if (removeStart > 0 && lines[removeStart - 1].trim() === '')
|
|
75
|
+
removeStart--;
|
|
76
|
+
if (removeEnd < lines.length && lines[removeEnd].trim() === '')
|
|
77
|
+
removeEnd++;
|
|
78
|
+
const remaining = [...lines.slice(0, removeStart), ...lines.slice(removeEnd)].join('\n');
|
|
79
|
+
fs.writeFileSync(filePath, remaining, 'utf8');
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// paths.ts — resuelve el directorio raíz del paquete instalado.
|
|
2
|
+
// Cuando el CLI corre como `trycore-build`, __dirname apunta a <pkg>/dist/lib.
|
|
3
|
+
// El paquete raíz está dos niveles arriba: <pkg>/dist/lib → <pkg>/.
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
/** Raíz del paquete instalado (donde viven agents/, skills/, hooks/, etc.) */
|
|
10
|
+
export const PACKAGE_ROOT = path.resolve(__dirname, '..', '..');
|
|
11
|
+
/** Versión leída desde el archivo VERSION del paquete. */
|
|
12
|
+
export function readPackageVersion() {
|
|
13
|
+
const versionFile = path.join(PACKAGE_ROOT, 'VERSION');
|
|
14
|
+
if (fs.existsSync(versionFile)) {
|
|
15
|
+
return fs.readFileSync(versionFile, 'utf8').trim();
|
|
16
|
+
}
|
|
17
|
+
const pkgFile = path.join(PACKAGE_ROOT, 'package.json');
|
|
18
|
+
if (fs.existsSync(pkgFile)) {
|
|
19
|
+
const pkg = JSON.parse(fs.readFileSync(pkgFile, 'utf8'));
|
|
20
|
+
return pkg.version ?? '0.0.0';
|
|
21
|
+
}
|
|
22
|
+
return '0.0.0';
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Rutas convencionales dentro del paquete (origen de los assets a instalar).
|
|
26
|
+
* El harness preserva sus rutas NATIVAS (agents/build, commands/opsx, …) — NO se
|
|
27
|
+
* reubica bajo un namespace envolvente, porque sus archivos se auto-referencian por ruta.
|
|
28
|
+
*/
|
|
29
|
+
export const ASSETS = {
|
|
30
|
+
agentsBuild: path.join(PACKAGE_ROOT, 'agents', 'build'),
|
|
31
|
+
commandsOpsx: path.join(PACKAGE_ROOT, 'commands', 'opsx'),
|
|
32
|
+
commandsBuild: path.join(PACKAGE_ROOT, 'commands', 'build'),
|
|
33
|
+
skills: path.join(PACKAGE_ROOT, 'skills'),
|
|
34
|
+
hooksBuild: path.join(PACKAGE_ROOT, 'hooks', 'build'),
|
|
35
|
+
// Declaración de hooks para el canal PLUGIN (no la instala el CLI; la consume el plugin nativo).
|
|
36
|
+
pluginHooks: path.join(PACKAGE_ROOT, 'hooks', 'build-harness.json'),
|
|
37
|
+
stateSchema: path.join(PACKAGE_ROOT, 'state', 'build-state.schema.json'),
|
|
38
|
+
stateReadme: path.join(PACKAGE_ROOT, 'state', 'README.md'),
|
|
39
|
+
stateTemplate: path.join(PACKAGE_ROOT, 'state', 'build-state.template.json'),
|
|
40
|
+
configTemplate: path.join(PACKAGE_ROOT, 'config', 'stack-allowlist.template.json'),
|
|
41
|
+
templateClaudeMd: path.join(PACKAGE_ROOT, 'templates', 'CLAUDE.md.template'),
|
|
42
|
+
settingsHooksTemplate: path.join(PACKAGE_ROOT, 'templates', 'settings-hooks.template.json'),
|
|
43
|
+
};
|
|
44
|
+
/** Rutas dentro del proyecto consumidor (relativas a `targetDir`). */
|
|
45
|
+
export function targetPaths(targetDir) {
|
|
46
|
+
const claudeDir = path.join(targetDir, '.claude');
|
|
47
|
+
return {
|
|
48
|
+
claudeDir,
|
|
49
|
+
agentsBuild: path.join(claudeDir, 'agents', 'build'),
|
|
50
|
+
commandsOpsx: path.join(claudeDir, 'commands', 'opsx'),
|
|
51
|
+
commandsBuild: path.join(claudeDir, 'commands', 'build'),
|
|
52
|
+
skillsDir: path.join(claudeDir, 'skills'),
|
|
53
|
+
hooksBuild: path.join(claudeDir, 'hooks', 'build'),
|
|
54
|
+
configDir: path.join(claudeDir, 'config'),
|
|
55
|
+
configFile: path.join(claudeDir, 'config', 'stack-allowlist.json'),
|
|
56
|
+
stateDir: path.join(claudeDir, 'state'),
|
|
57
|
+
stateSchema: path.join(claudeDir, 'state', 'build-state.schema.json'),
|
|
58
|
+
stateReadme: path.join(claudeDir, 'state', 'README.md'),
|
|
59
|
+
stateFile: path.join(claudeDir, 'state', 'build-state.json'),
|
|
60
|
+
settingsFile: path.join(claudeDir, 'settings.json'),
|
|
61
|
+
versionFile: path.join(claudeDir, '.build-harness-version'),
|
|
62
|
+
claudeMd: path.join(targetDir, 'CLAUDE.md'),
|
|
63
|
+
gitignore: path.join(targetDir, '.gitignore'),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// settings-merge.ts — merge idempotente de hooks + permisos mínimos en el
|
|
2
|
+
// settings.json del consumidor (canal npm CLI). Fuente de verdad de las entradas.
|
|
3
|
+
//
|
|
4
|
+
// Diseño (corrige el doble disparo [H3] y la remoción frágil [M3]):
|
|
5
|
+
// - UNA cadena de comando autorresolutiva, IDÉNTICA a la del canal plugin
|
|
6
|
+
// (hooks/build-harness.json). Si el consumidor instala por CLI Y por plugin,
|
|
7
|
+
// las cadenas son idénticas → Claude Code las deduplica → el hook dispara UNA vez.
|
|
8
|
+
// - Remoción por set EXACTO de cadenas conocidas (no substring de ruta).
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
// Cadena autorresolutiva: con plugin → ${CLAUDE_PLUGIN_ROOT}/hooks/build/…;
|
|
11
|
+
// sin plugin (CLI puro) → $CLAUDE_PROJECT_DIR/.claude/hooks/build/…
|
|
12
|
+
const CHAIN = '${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR/.claude}/hooks/build/';
|
|
13
|
+
/** Construye la cadena de comando (entre comillas, para rutas con espacios). */
|
|
14
|
+
function cmd(script) {
|
|
15
|
+
return `"${CHAIN}${script}"`;
|
|
16
|
+
}
|
|
17
|
+
/** Las 5 agrupaciones de hooks del arnés (= las de hooks/build-harness.json). */
|
|
18
|
+
const HOOK_SPECS = [
|
|
19
|
+
{ event: 'SessionStart', matcher: 'startup|clear|compact', scripts: ['load-build-state.sh'] },
|
|
20
|
+
{ event: 'PreToolUse', matcher: 'Bash', scripts: ['gitflow-guard.sh'] },
|
|
21
|
+
{ event: 'PreToolUse', matcher: 'Write|Edit|MultiEdit', scripts: ['stack-guard.sh'] },
|
|
22
|
+
{ event: 'PostToolUse', matcher: 'Write|Edit|MultiEdit', scripts: ['lint-typecheck.sh', 'coherence-flag.sh'] },
|
|
23
|
+
{ event: 'Stop', matcher: '.*', scripts: ['build-gate-check.sh'] },
|
|
24
|
+
];
|
|
25
|
+
/** Permisos MÍNIMOS y enumerados [H11]. Nunca permisos amplios (mcp__*, additionalDirectories…). */
|
|
26
|
+
const MIN_PERMISSIONS = [
|
|
27
|
+
'Bash(openspec validate *)',
|
|
28
|
+
'Bash(openspec list *)',
|
|
29
|
+
'Bash(openspec show *)',
|
|
30
|
+
];
|
|
31
|
+
/** Todas las cadenas de comando que este paquete inyecta (para remoción exacta). */
|
|
32
|
+
function ownedCommands() {
|
|
33
|
+
const set = new Set();
|
|
34
|
+
for (const spec of HOOK_SPECS)
|
|
35
|
+
for (const s of spec.scripts)
|
|
36
|
+
set.add(cmd(s));
|
|
37
|
+
return set;
|
|
38
|
+
}
|
|
39
|
+
function readSettings(file) {
|
|
40
|
+
if (!fs.existsSync(file))
|
|
41
|
+
return {};
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
throw new Error(`settings.json malformado: ${file}. Corrígelo o muévelo antes de reintentar.`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function writeSettings(file, s) {
|
|
50
|
+
fs.writeFileSync(file, JSON.stringify(s, null, 2) + '\n', 'utf8');
|
|
51
|
+
}
|
|
52
|
+
function existingCommands(settings, event) {
|
|
53
|
+
const out = new Set();
|
|
54
|
+
for (const group of settings.hooks?.[event] ?? []) {
|
|
55
|
+
for (const h of group.hooks ?? [])
|
|
56
|
+
out.add(h.command);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
/** Mergea hooks + permisos mínimos. Idempotente; aditivo (no pisa lo del consumidor). */
|
|
61
|
+
export function mergeHarnessSettings(settingsFile) {
|
|
62
|
+
const settings = readSettings(settingsFile);
|
|
63
|
+
settings.hooks ??= {};
|
|
64
|
+
let addedHooks = 0;
|
|
65
|
+
for (const spec of HOOK_SPECS) {
|
|
66
|
+
const present = existingCommands(settings, spec.event);
|
|
67
|
+
const newHooks = spec.scripts
|
|
68
|
+
.filter((s) => !present.has(cmd(s)))
|
|
69
|
+
.map((s) => ({ type: 'command', command: cmd(s) }));
|
|
70
|
+
if (newHooks.length === 0)
|
|
71
|
+
continue;
|
|
72
|
+
settings.hooks[spec.event] ??= [];
|
|
73
|
+
settings.hooks[spec.event].push({ matcher: spec.matcher, hooks: newHooks });
|
|
74
|
+
addedHooks += newHooks.length;
|
|
75
|
+
}
|
|
76
|
+
settings.permissions ??= {};
|
|
77
|
+
settings.permissions.allow ??= [];
|
|
78
|
+
const allow = settings.permissions.allow;
|
|
79
|
+
let addedPerms = 0;
|
|
80
|
+
for (const p of MIN_PERMISSIONS) {
|
|
81
|
+
if (!allow.includes(p)) {
|
|
82
|
+
allow.push(p);
|
|
83
|
+
addedPerms++;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
writeSettings(settingsFile, settings);
|
|
87
|
+
return { addedHooks, addedPerms };
|
|
88
|
+
}
|
|
89
|
+
/** Quita SOLO lo que este paquete inyectó (por cadena exacta). Preserva lo demás. */
|
|
90
|
+
export function removeHarnessSettings(settingsFile) {
|
|
91
|
+
if (!fs.existsSync(settingsFile))
|
|
92
|
+
return false;
|
|
93
|
+
const settings = readSettings(settingsFile);
|
|
94
|
+
const owned = ownedCommands();
|
|
95
|
+
let changed = false;
|
|
96
|
+
if (settings.hooks) {
|
|
97
|
+
for (const event of Object.keys(settings.hooks)) {
|
|
98
|
+
const groups = settings.hooks[event];
|
|
99
|
+
for (const group of groups) {
|
|
100
|
+
const before = group.hooks?.length ?? 0;
|
|
101
|
+
if (group.hooks)
|
|
102
|
+
group.hooks = group.hooks.filter((h) => !owned.has(h.command));
|
|
103
|
+
if ((group.hooks?.length ?? 0) !== before)
|
|
104
|
+
changed = true;
|
|
105
|
+
}
|
|
106
|
+
// Drop grupos sin hooks
|
|
107
|
+
settings.hooks[event] = groups.filter((g) => (g.hooks?.length ?? 0) > 0);
|
|
108
|
+
if (settings.hooks[event].length === 0)
|
|
109
|
+
delete settings.hooks[event];
|
|
110
|
+
}
|
|
111
|
+
if (Object.keys(settings.hooks).length === 0)
|
|
112
|
+
delete settings.hooks;
|
|
113
|
+
}
|
|
114
|
+
if (settings.permissions?.allow) {
|
|
115
|
+
const allow = settings.permissions.allow;
|
|
116
|
+
const filtered = allow.filter((p) => !MIN_PERMISSIONS.includes(p));
|
|
117
|
+
if (filtered.length !== allow.length) {
|
|
118
|
+
settings.permissions.allow = filtered;
|
|
119
|
+
changed = true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (changed)
|
|
123
|
+
writeSettings(settingsFile, settings);
|
|
124
|
+
return changed;
|
|
125
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// stack-prompt.ts — captura el STACK MECÁNICO del consumidor (capa CLI del onboarding).
|
|
2
|
+
// Los parámetros de JUICIO (PII, capa IA/externa, capa determinista) NO se capturan aquí:
|
|
3
|
+
// los resuelve el slash command /build:onboard, ejecutado por Claude leyendo el PRD [H5,H8].
|
|
4
|
+
//
|
|
5
|
+
// CI-safe [H6]: si no hay TTY o se pasa --yes, usa defaults sin bloquear.
|
|
6
|
+
import * as readline from 'node:readline/promises';
|
|
7
|
+
import { stdin, stdout } from 'node:process';
|
|
8
|
+
const DEFAULTS = {
|
|
9
|
+
pkgManager: 'npm',
|
|
10
|
+
runtime: '>=18.18',
|
|
11
|
+
prdPath: 'docs/01-prd/<tu-prd>.md#requisitos-tecnicos',
|
|
12
|
+
};
|
|
13
|
+
function parseList(s) {
|
|
14
|
+
if (!s)
|
|
15
|
+
return [];
|
|
16
|
+
return s
|
|
17
|
+
.split(',')
|
|
18
|
+
.map((x) => x.trim())
|
|
19
|
+
.filter(Boolean);
|
|
20
|
+
}
|
|
21
|
+
/** ¿Podemos preguntar interactivamente? Solo si hay TTY y no se pidió --yes. */
|
|
22
|
+
function canPrompt(flags) {
|
|
23
|
+
return !flags.yes && Boolean(stdin.isTTY) && Boolean(stdout.isTTY);
|
|
24
|
+
}
|
|
25
|
+
export async function captureStack(flags) {
|
|
26
|
+
// Si todos los datos vienen por flags, no preguntamos.
|
|
27
|
+
const haveAll = flags.stack !== undefined && flags.pkgManager && flags.runtime && flags.prdPath;
|
|
28
|
+
if (haveAll || !canPrompt(flags)) {
|
|
29
|
+
return {
|
|
30
|
+
cfg: {
|
|
31
|
+
allow: parseList(flags.stack),
|
|
32
|
+
pkgManager: flags.pkgManager ?? DEFAULTS.pkgManager,
|
|
33
|
+
runtime: flags.runtime ?? DEFAULTS.runtime,
|
|
34
|
+
prdPath: flags.prdPath ?? DEFAULTS.prdPath,
|
|
35
|
+
},
|
|
36
|
+
interactive: false,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
40
|
+
try {
|
|
41
|
+
stdout.write('\n Configuración del stack (Enter para usar el valor por defecto):\n');
|
|
42
|
+
const allowRaw = flags.stack ?? (await rl.question(' Dependencias/paquetes permitidos (coma-separadas) []: '));
|
|
43
|
+
const pkgManager = flags.pkgManager || (await rl.question(` Package manager [${DEFAULTS.pkgManager}]: `)) || DEFAULTS.pkgManager;
|
|
44
|
+
const runtime = flags.runtime || (await rl.question(` Runtime (semver del motor) [${DEFAULTS.runtime}]: `)) || DEFAULTS.runtime;
|
|
45
|
+
const prdPath = flags.prdPath ||
|
|
46
|
+
(await rl.question(` Ruta#ancla del PRD técnico [${DEFAULTS.prdPath}]: `)) ||
|
|
47
|
+
DEFAULTS.prdPath;
|
|
48
|
+
return {
|
|
49
|
+
cfg: { allow: parseList(allowRaw), pkgManager, runtime, prdPath },
|
|
50
|
+
interactive: true,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
rl.close();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Renderiza el contenido de stack-allowlist.json desde el stack capturado. */
|
|
58
|
+
export function renderAllowlist(cfg) {
|
|
59
|
+
return {
|
|
60
|
+
_comment: 'Allowlist de dependencias del proyecto. Derivada de la sección de requisitos técnicos del PRD. ' +
|
|
61
|
+
'El hook stack-guard.sh bloquea dependencias fuera de `allow` al editar package.json.',
|
|
62
|
+
source: cfg.prdPath,
|
|
63
|
+
version: '1.0',
|
|
64
|
+
runtime: { node: cfg.runtime, packageManager: cfg.pkgManager },
|
|
65
|
+
allow: cfg.allow,
|
|
66
|
+
deny_examples: [],
|
|
67
|
+
rationale: {},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// state-seed.ts — siembra el estado del arnés en el consumidor.
|
|
2
|
+
// Política [C1]: schema + README se VERSIONAN y refrescan en cada init/update;
|
|
3
|
+
// el estado vivo (build-state.json) se siembra UNA vez desde un template VACÍO
|
|
4
|
+
// y NUNCA se sobreescribe (es propiedad del equipo).
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { ASSETS, targetPaths } from './paths.js';
|
|
8
|
+
import { ensureDir } from './install-engine.js';
|
|
9
|
+
/** Estado inicial vacío (fallback si falta el template del paquete). */
|
|
10
|
+
const EMPTY_STATE = {
|
|
11
|
+
version: '1.0',
|
|
12
|
+
harness_phase: 'authoring',
|
|
13
|
+
active_slice: null,
|
|
14
|
+
history: [],
|
|
15
|
+
releases: [],
|
|
16
|
+
};
|
|
17
|
+
export function seedState(targetDir) {
|
|
18
|
+
const t = targetPaths(targetDir);
|
|
19
|
+
ensureDir(t.stateDir);
|
|
20
|
+
// schema + README: assets versionados, se refrescan siempre.
|
|
21
|
+
if (fs.existsSync(ASSETS.stateSchema))
|
|
22
|
+
fs.copyFileSync(ASSETS.stateSchema, t.stateSchema);
|
|
23
|
+
if (fs.existsSync(ASSETS.stateReadme))
|
|
24
|
+
fs.copyFileSync(ASSETS.stateReadme, t.stateReadme);
|
|
25
|
+
// build-state.json: solo si NO existe. Si existe, intacto.
|
|
26
|
+
let stateSeeded = false;
|
|
27
|
+
if (!fs.existsSync(t.stateFile)) {
|
|
28
|
+
if (fs.existsSync(ASSETS.stateTemplate)) {
|
|
29
|
+
fs.copyFileSync(ASSETS.stateTemplate, t.stateFile);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
fs.writeFileSync(t.stateFile, JSON.stringify(EMPTY_STATE, null, 2) + '\n', 'utf8');
|
|
33
|
+
}
|
|
34
|
+
stateSeeded = true;
|
|
35
|
+
}
|
|
36
|
+
return { stateSeeded };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Siembra el stack-allowlist.json del consumidor (artefacto del consumidor, no del paquete).
|
|
40
|
+
* Solo si NO existe. Acepta un objeto ya resuelto con el stack capturado por el CLI;
|
|
41
|
+
* si es null, copia el template tal cual.
|
|
42
|
+
*/
|
|
43
|
+
export function seedConfig(targetDir, allowlist) {
|
|
44
|
+
const t = targetPaths(targetDir);
|
|
45
|
+
ensureDir(t.configDir);
|
|
46
|
+
if (fs.existsSync(t.configFile))
|
|
47
|
+
return { configSeeded: false };
|
|
48
|
+
if (allowlist) {
|
|
49
|
+
fs.writeFileSync(t.configFile, JSON.stringify(allowlist, null, 2) + '\n', 'utf8');
|
|
50
|
+
}
|
|
51
|
+
else if (fs.existsSync(ASSETS.configTemplate)) {
|
|
52
|
+
fs.copyFileSync(ASSETS.configTemplate, t.configFile);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
return { configSeeded: false };
|
|
56
|
+
}
|
|
57
|
+
void path;
|
|
58
|
+
return { configSeeded: true };
|
|
59
|
+
}
|