jorgex-stack 1.0.3 → 1.0.4
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/PRD.md +2 -2
- package/README.md +33 -3
- package/dist/cli.js +2 -3
- package/package.json +2 -2
- package/stack/plugins/opencode/goal/artifacts.ts +142 -0
- package/stack/plugins/opencode/goal/command.ts +255 -0
- package/stack/plugins/opencode/goal/db.ts +68 -0
- package/stack/plugins/opencode/goal/opencode-hooks.ts +272 -0
- package/stack/plugins/opencode/goal/state.ts +85 -0
- package/stack/plugins/opencode/goal/store.ts +906 -0
- package/stack/plugins/opencode/goal/supervisor.ts +269 -0
- package/stack/plugins/opencode/goal/types.ts +187 -0
- package/stack/plugins/opencode/goal-plugin.ts +176 -0
package/PRD.md
CHANGED
|
@@ -31,7 +31,7 @@ pnpm dlx jorgex-stack doctor → verifica que todo está sano
|
|
|
31
31
|
|
|
32
32
|
| # | Decisión | Elección |
|
|
33
33
|
|---|----------|----------|
|
|
34
|
-
| D1 | Tecnología del instalador | **TypeScript + Node (≥
|
|
34
|
+
| D1 | Tecnología del instalador | **TypeScript + Node (≥22.5)**, bundle único (tsup/esbuild), prompts con `@clack/prompts`, publicado en el registry npm y ejecutado con `pnpm dlx jorgex-stack`. Sin Go, sin binarios propios. |
|
|
35
35
|
| D2 | Plataformas | **Cross-platform desde v1** (Windows + macOS + Linux). Windows es el entorno principal de pruebas. |
|
|
36
36
|
| D3 | Estrategia de despliegue | **Merge idempotente con marcadores** (`<!-- jorgex:seccion -->` en markdown, upsert quirúrgico en JSON/TOML). Backup automático antes de tocar nada + rollback. Nunca machaca contenido manual del usuario. |
|
|
37
37
|
| D4 | Plugins de jorgex-custom-tools | Se **copian** al nuevo repo ahora (los originales NO se tocan porque están en uso). **Cuando el proyecto esté completo e instalado**: se eliminan de `C:\Users\jorge\Desktop\jorgex-custom-tools` y pasan a vivir/instalarse SOLO desde JorgeX Stack. Ver §11 F6. |
|
|
@@ -217,7 +217,7 @@ Solo dos MCPs en el stack (D5):
|
|
|
217
217
|
El paquete `jorgex-stack` se publica solo, sin acción del usuario. El workflow `.github/workflows/publish.yml` corre en cada push a `main` y aplica esta política:
|
|
218
218
|
|
|
219
219
|
1. **Detección de cambios publicables**: se compara `HEAD` contra `v<package.version>` si ese tag existe; si no, cae a `github.event.before`. Son publicables los de `src/`, `stack/` y la lista exacta (`upstreams.json`, `package.json`, `pnpm-lock.yaml`, `tsconfig.json`, `tsup.config.ts`, `README.md`, `PRD.md`). **No** son publicables los cambios solo en `work/`, `worktrees/`, tests, ni en archivos fuera de la lista. La política está modelada y testeada en `src/lib/release.ts` (`classifyReleasePaths`); el workflow la replica inline para no ejecutar código generado por el repo en el job privilegiado.
|
|
220
|
-
2. **Patch automático + recuperación manual**: si hay cambios publicables y la versión de `package.json` ya existe en npm, el workflow busca el primer patch libre (`x+1`, `x+2`, …) con `bumpPatch` (`src/lib/release.ts`), commitea `chore(release): bump version to v…` con el actor `github-actions[bot]` y publica la nueva versión. `validate` resuelve una sola vez la SHA objetivo y la expone como `target_sha`; `bump` la reutiliza y solo falla verde en `stale_run` cuando una run de push quedó vieja tras `git fetch origin main --tags` y `origin/main` ya no coincide con la SHA validada. Un `workflow_dispatch` sobre `main` recupera una publicación fallida: si no se pasa `release_sha`, `validate` fija `target_sha` a `origin/main` tras el fetch; si se pasa, debe ser una SHA completa de 40 hex perteneciente a `main` y se valida su `package.json.version` antes de publicar o tagear. Si la `release_sha` no es válida o no pertenece a `main`, el job falla en rojo con mensaje accionable; `workflow_dispatch` nunca emite `skip_reason=stale_run`. Si no hay cambios publicables en un push normal: no hace nada.
|
|
220
|
+
2. **Patch automático + recuperación manual**: si hay cambios publicables y la versión de `package.json` ya existe en npm, el workflow busca el primer patch libre (`x+1`, `x+2`, …) con `bumpPatch` (`src/lib/release.ts`), commitea `chore(release): bump version to v…` con el actor `github-actions[bot]` y publica la nueva versión. `validate` resuelve una sola vez la SHA objetivo y la expone como `target_sha`; `bump` la reutiliza y solo falla verde en `stale_run` cuando una run de push quedó vieja tras `git fetch origin main --tags` y `origin/main` ya no coincide con la SHA validada. Un `workflow_dispatch` sobre `main` recupera una publicación fallida: si no se pasa `release_sha`, `validate` fija `target_sha` a `origin/main` tras el fetch; si se pasa, debe ser una SHA completa de 40 hex perteneciente a `main` y se valida su `package.json.version` antes de publicar o tagear. Si la `release_sha` no es válida o no pertenece a `main`, el job falla en rojo con mensaje accionable; `workflow_dispatch` nunca emite `skip_reason=stale_run`. Si el diff mezcla cambios publicables con `.github/workflows/*`, el auto-release se aborta antes de bump/publish porque GitHub puede rechazar el push del tag sin permisos para workflows; hay que separar la release o usar una publicación/tag manual con permisos elevados. Si no hay un tag de release previo alcanzable para reconstruir el rango de recovery, el workflow falla cerrado y exige intervención manual. Si no hay cambios publicables en un push normal: no hace nada.
|
|
221
221
|
3. **Minor y major manuales**: cuando el siguiente patch ya existe en npm (p.ej. el workflow detectó que `1.0.3` está ocupado), falla con mensaje claro y exige bump manual de `package.json` en un PR. Minor y major siguen siendo decisiones humanas.
|
|
222
222
|
4. **Guarda anti-loop**: `isReleaseBumpCommit` reconoce solo `chore(release):`, semver puro (`1.0.3`, `v1.0.3`) y commits de actores que terminan en `[bot]` y mencionan release/publish/bump/version. `release:` genérico y `chore:` a secas no cuentan. Cuando detecta uno, no vuelve a bumpear ni a publicar.
|
|
223
223
|
5. **OIDC / trusted publishing**: el job de bump/push usa solo `contents: write`; el job de publish usa `permissions: id-token: write` + `contents: read` y `setup-node` con `registry-url: https://registry.npmjs.org`; el `tag-release` solo usa `contents: write` y no necesita OIDC. **No** se usan `NPM_TOKEN` ni `NODE_AUTH_TOKEN` — los únicos secretos del repo son los de GitHub. La excepción a la regla D8 ("pnpm siempre") son `npm pack --dry-run --ignore-scripts` y el `npm publish --ignore-scripts --provenance` final: el cliente npm permite fijar `--ignore-scripts` y publicar con OIDC/provenance contra el registry oficial.
|
package/README.md
CHANGED
|
@@ -39,16 +39,46 @@ Uso:
|
|
|
39
39
|
|
|
40
40
|
Autenticación con GitHub: las consultas usan `GH_TOKEN`/`GITHUB_TOKEN` del entorno o, si no existen, el token de tu sesión de `gh` CLI (`gh auth token` — solo lectura local, nunca se loguea ni persiste). Sin token, GitHub limita las consultas en paralelo y algunos upstreams pueden salir como "sin conexión".
|
|
41
41
|
|
|
42
|
+
### Goal Mode de OpenCode
|
|
43
|
+
|
|
44
|
+
Goal Mode es un plugin de OpenCode para objetivos largos: varias sesiones, varios slices, varios worktrees y, si hace falta, varios PRs. No está pensado para tareas cortas. Si el cambio cabe sin autonomía prolongada, no uses `/goal`.
|
|
45
|
+
|
|
46
|
+
Solo vive en OpenCode. Claude Code y Codex no lo reciben.
|
|
47
|
+
|
|
48
|
+
Comandos disponibles:
|
|
49
|
+
|
|
50
|
+
- `/goal <objetivo>` — crea un goal persistente.
|
|
51
|
+
- `/goal status` — muestra estado y siguiente acción.
|
|
52
|
+
- `/goal plan` — enseña el plan maestro / PRD del goal.
|
|
53
|
+
- `/goal history` — lista eventos y transiciones.
|
|
54
|
+
- `/goal pause` — pausa el goal.
|
|
55
|
+
- `/goal resume` — reanuda el goal.
|
|
56
|
+
- `/goal merged [commit]` — señala que el PR externo pendiente ya se ha mergeado.
|
|
57
|
+
- `/goal cancel` — cancela el goal.
|
|
58
|
+
|
|
59
|
+
Lo que no existe:
|
|
60
|
+
|
|
61
|
+
- `/goal quick`
|
|
62
|
+
- `/goal work`
|
|
63
|
+
|
|
64
|
+
Estado operativo:
|
|
65
|
+
|
|
66
|
+
- SQLite separada por defecto en `~/.jorgex-stack/goals/goals.sqlite`.
|
|
67
|
+
- Override opcional con `JORGEX_GOAL_DB`, pero siempre dentro de `~/.jorgex-stack/goals/`.
|
|
68
|
+
- Engram no es el store operativo del goal: sigue siendo memoria/protocolo, no base de estado.
|
|
69
|
+
- Goal Mode no hace merges automáticos; cuando toca esperar un merge externo, el estado pasa a `waiting_for_merge`.
|
|
70
|
+
- La integración usa hooks experimentales de OpenCode (`experimental.chat.system.transform` y `experimental.session.compacting`), así que esa superficie puede cambiar.
|
|
71
|
+
|
|
42
72
|
## Estado
|
|
43
73
|
|
|
44
74
|
CLI completo y migración real ejecutada (F6); el stack es la única fuente de configuración. Las versiones se publican automáticamente en [npm](https://www.npmjs.com/package/jorgex-stack) según el flujo descrito en [Publicación](#publicación). El diseño, las decisiones (D1–D9) y el roadmap están en [PRD.md](PRD.md).
|
|
45
75
|
|
|
46
76
|
## Publicación
|
|
47
77
|
|
|
48
|
-
La release la dispara el push/merge a `main` y GitHub Actions; también hay `workflow_dispatch` de recuperación sobre `main` con `release_sha` opcional. `validate` resuelve una sola vez la SHA objetivo y la expone como `target_sha`; `bump` reutiliza esa SHA. Si no pasas `release_sha`, `validate` fija `target_sha` a `origin/main` tras `fetch`; si la pasas, debe ser una SHA completa de 40 hex perteneciente a `main` o falla en rojo con instrucción de recuperación. El modo sin `release_sha` solo es válido para publicar `origin/main` cuando la versión aún no existe en npm; si la versión ya existe y falta el tag, el workflow falla y exige `workflow_dispatch` con `release_sha=<sha publicada>`. No se usa `pnpm publish` ni hace falta login de npm:
|
|
78
|
+
La release la dispara el push/merge a `main` y GitHub Actions; también hay `workflow_dispatch` de recuperación sobre `main` con `release_sha` opcional. `validate` resuelve una sola vez la SHA objetivo y la expone como `target_sha`; `bump` reutiliza esa SHA. Si no pasas `release_sha`, `validate` fija `target_sha` a `origin/main` tras `fetch`; si la pasas, debe ser una SHA completa de 40 hex perteneciente a `main` o falla en rojo con instrucción de recuperación. El modo sin `release_sha` solo es válido para publicar `origin/main` cuando la versión aún no existe en npm; si la versión ya existe y falta el tag, el workflow falla y exige `workflow_dispatch` con `release_sha=<sha publicada>`. Si el diff mezcla cambios publicables con `.github/workflows/*`, el auto-release se corta antes de bump/publish porque GitHub puede rechazar el push del tag sin permisos para workflows; hay que separar la release o usar un publish/tag manual con permisos elevados. No se usa `pnpm publish` ni hace falta login de npm:
|
|
49
79
|
|
|
50
80
|
- **Patch automático**: si el push a `main` contiene cambios publicables y la versión actual de `package.json` ya está en npm, el workflow busca el primer patch libre (`x+1`, `x+2`, …), commitea `chore(release): bump version to v…` y publica. Si ya existe el tag `v<package.version>`, usa ese punto como base acumulada; si no, cae a `github.event.before`. Las runs obsoletas se abortan tras `git fetch origin main --tags` si `origin/main` ya no coincide con `GITHUB_SHA`.
|
|
51
|
-
- **Recuperación manual**: una ejecución manual sobre `main` con `release_sha` publica esa SHA si todavía no existe en npm, sin volver a bumpear; si la versión ya está en npm pero falta el tag `v<version>`, el workflow falla y te obliga a relanzar con `release_sha=<sha publicada>` para no tagear `origin/main`. `release_sha` debe ser una SHA completa de 40 hex y pertenecer a `main`; refs mutables (`main`, tags, `main~1`) se rechazan. Si no pasas `release_sha`, `validate` resuelve `origin/main` una vez, lo expone como `target_sha` y `bump` usa esa SHA validada.
|
|
81
|
+
- **Recuperación manual**: una ejecución manual sobre `main` con `release_sha` publica esa SHA si todavía no existe en npm, sin volver a bumpear; si la versión ya está en npm pero falta el tag `v<version>`, el workflow falla y te obliga a relanzar con `release_sha=<sha publicada>` para no tagear `origin/main`. `release_sha` debe ser una SHA completa de 40 hex y pertenecer a `main`; refs mutables (`main`, tags, `main~1`) se rechazan. Si no pasas `release_sha`, `validate` resuelve `origin/main` una vez, lo expone como `target_sha` y `bump` usa esa SHA validada. La recuperación no salta la guarda de `.github/workflows/*`: si el diff mezcla workflows con cambios publicables, hay que separar la release o hacer el tag/publish con permisos elevados. Si no existe un tag de release previo alcanzable para reconstruir el rango, el workflow falla cerrado y exige intervención manual.
|
|
52
82
|
- **Sin release**: cambios solo en `work/`, `worktrees/`, tests o archivos no listados como publicables (`src/`, `stack/`, `upstreams.json`, `package.json`, `pnpm-lock.yaml`, `tsconfig.json`, `tsup.config.ts`, `README.md`, `PRD.md`) no generan release.
|
|
53
83
|
- **Minor y major manuales**: bump explícito de `package.json` en el PR (el workflow detecta que el siguiente patch ya existe en npm y exige el bump).
|
|
54
84
|
- **OIDC / trusted publishing**: el job de publicación usa `id-token: write` y `registry-url` de `setup-node`; el job de bump/push solo tiene `contents: write`; el `tag-release` solo escribe `contents` y no usa OIDC. No hay `NPM_TOKEN` ni `NODE_AUTH_TOKEN` en ningún secreto. El `tag-release` solo corre si `publish` fue `success` o `skipped` con `tag_needed=true` y conserva su validación SHA como última defensa. La única excepción a la regla "pnpm siempre" son `npm pack --dry-run --ignore-scripts` y `npm publish --ignore-scripts --provenance` en el paso final, por compatibilidad y hardening del registry.
|
|
@@ -57,7 +87,7 @@ Los detalles de diseño están en [PRD §7.6](PRD.md#76-publicación-automática
|
|
|
57
87
|
|
|
58
88
|
## Desarrollo
|
|
59
89
|
|
|
60
|
-
Requisitos: Node ≥
|
|
90
|
+
Requisitos: Node ≥ 22.5 y pnpm (nunca npm). Goal Mode usa `node:sqlite` en tests/CLI Node y OpenCode usa `bun:sqlite` en runtime.
|
|
61
91
|
|
|
62
92
|
```
|
|
63
93
|
pnpm install
|
package/dist/cli.js
CHANGED
|
@@ -1271,9 +1271,8 @@ function planPlugins(adapter, ctx) {
|
|
|
1271
1271
|
if (pluginsDir === null) return [];
|
|
1272
1272
|
const source = path16.join(ctx.stackDir, "plugins", adapter.id);
|
|
1273
1273
|
if (!fs10.existsSync(source)) return [];
|
|
1274
|
-
return
|
|
1275
|
-
const
|
|
1276
|
-
const target = path16.join(pluginsDir, f);
|
|
1274
|
+
return listFilesRecursive(source).filter((f) => f.endsWith(".ts")).map((sourceFile) => {
|
|
1275
|
+
const target = path16.join(pluginsDir, path16.relative(source, sourceFile));
|
|
1277
1276
|
const raw = fs10.readFileSync(sourceFile, "utf8");
|
|
1278
1277
|
let content = raw;
|
|
1279
1278
|
if (content.includes('"{{ENGRAM_BIN}}"')) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jorgex-stack",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Harness multi-agente portable: instala la config JorgeX (agentes, skills, hooks, Engram, MCPs) en Claude Code, Codex CLI y OpenCode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"PRD.md"
|
|
29
29
|
],
|
|
30
30
|
"engines": {
|
|
31
|
-
"node": ">=
|
|
31
|
+
"node": ">=22.5"
|
|
32
32
|
},
|
|
33
33
|
"packageManager": "pnpm@11.1.1",
|
|
34
34
|
"scripts": {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { GoalStore } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export interface MasterArtifactsInput {
|
|
6
|
+
store: GoalStore;
|
|
7
|
+
goalId: string;
|
|
8
|
+
rootDir: string;
|
|
9
|
+
allowedRootDir?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface MasterArtifactsResult {
|
|
13
|
+
created: boolean;
|
|
14
|
+
preserved: boolean;
|
|
15
|
+
prdPath: string;
|
|
16
|
+
planPath: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createMasterArtifacts(input: MasterArtifactsInput): MasterArtifactsResult {
|
|
20
|
+
const goal = input.store.getGoal(input.goalId);
|
|
21
|
+
if (!goal) {
|
|
22
|
+
throw new Error(`Goal ${input.goalId} not found.`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const allowedRootDir = input.allowedRootDir ?? input.rootDir;
|
|
26
|
+
assertSafeArtifactPath(input.rootDir, allowedRootDir, "Goal artifact root");
|
|
27
|
+
fs.mkdirSync(input.rootDir, { recursive: true });
|
|
28
|
+
|
|
29
|
+
const prdPath = path.join(input.rootDir, "PRD.md");
|
|
30
|
+
const planPath = path.join(input.rootDir, "plan.md");
|
|
31
|
+
assertSafeArtifactPath(prdPath, allowedRootDir, "Goal PRD artifact");
|
|
32
|
+
assertSafeArtifactPath(planPath, allowedRootDir, "Goal plan artifact");
|
|
33
|
+
const prdCreated = writeIfMissing(prdPath, renderMasterPrd(goal.objective));
|
|
34
|
+
const planCreated = writeIfMissing(planPath, renderMasterPlan(goal.objective));
|
|
35
|
+
|
|
36
|
+
input.store.recordArtifact(goal.id, { kind: "prd", path: prdPath });
|
|
37
|
+
input.store.recordArtifact(goal.id, { kind: "plan", path: planPath });
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
created: prdCreated || planCreated,
|
|
41
|
+
preserved: !prdCreated || !planCreated,
|
|
42
|
+
prdPath,
|
|
43
|
+
planPath,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function assertSafeArtifactPath(filePath: string, allowedRootDir: string, label = "Goal artifact"): void {
|
|
48
|
+
const resolvedAllowedRoot = resolveExistingPathWithoutSymlinks(allowedRootDir, `${label} root`);
|
|
49
|
+
const resolvedPath = resolveExistingPathWithoutSymlinks(filePath, label);
|
|
50
|
+
if (!isContainedIn(resolvedPath, resolvedAllowedRoot)) {
|
|
51
|
+
throw new Error(`${label} must stay inside ${allowedRootDir}. Refusing: ${filePath}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const stats = lstatIfExists(filePath);
|
|
55
|
+
if (stats?.isFile() && stats.nlink > 1) {
|
|
56
|
+
throw new Error(`${label} must not be a hard link.`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function writeIfMissing(filePath: string, content: string): boolean {
|
|
61
|
+
if (fs.existsSync(filePath)) return false;
|
|
62
|
+
fs.writeFileSync(filePath, content, "utf8");
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolveExistingPathWithoutSymlinks(input: string, label: string): string {
|
|
67
|
+
assertNoSymlinkInExistingPath(input, label);
|
|
68
|
+
let current = path.resolve(input);
|
|
69
|
+
const missing: string[] = [];
|
|
70
|
+
while (!fs.existsSync(current)) {
|
|
71
|
+
missing.push(path.basename(current));
|
|
72
|
+
const parent = path.dirname(current);
|
|
73
|
+
if (parent === current) break;
|
|
74
|
+
current = parent;
|
|
75
|
+
}
|
|
76
|
+
const real = fs.existsSync(current) ? fs.realpathSync(current) : current;
|
|
77
|
+
return missing.reduceRight((base, part) => path.join(base, part), real);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function assertNoSymlinkInExistingPath(input: string, label: string): void {
|
|
81
|
+
const resolved = path.resolve(input);
|
|
82
|
+
const root = path.parse(resolved).root;
|
|
83
|
+
const relativeParts = path.relative(root, resolved).split(path.sep).filter(Boolean);
|
|
84
|
+
let current = root;
|
|
85
|
+
for (const part of relativeParts) {
|
|
86
|
+
current = path.join(current, part);
|
|
87
|
+
const stats = lstatIfExists(current);
|
|
88
|
+
if (!stats) return;
|
|
89
|
+
if (stats.isSymbolicLink()) {
|
|
90
|
+
throw new Error(`${label} must not include symlinks.`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function lstatIfExists(input: string): fs.Stats | undefined {
|
|
96
|
+
try {
|
|
97
|
+
return fs.lstatSync(input);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isContainedIn(candidate: string, root: string): boolean {
|
|
105
|
+
const relative = path.relative(root, candidate);
|
|
106
|
+
return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function renderMasterPrd(objective: string): string {
|
|
110
|
+
return [
|
|
111
|
+
"# PRD maestro",
|
|
112
|
+
"",
|
|
113
|
+
"## Objetivo",
|
|
114
|
+
"",
|
|
115
|
+
objective,
|
|
116
|
+
"",
|
|
117
|
+
"## Alcance",
|
|
118
|
+
"",
|
|
119
|
+
"- Mantener el objetivo global del Goal Mode.",
|
|
120
|
+
"- Dividir el trabajo en slices ejecutables por el orquestador.",
|
|
121
|
+
"- Esperar merges manuales antes de continuar.",
|
|
122
|
+
"",
|
|
123
|
+
].join("\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function renderMasterPlan(objective: string): string {
|
|
127
|
+
return [
|
|
128
|
+
"# Plan maestro",
|
|
129
|
+
"",
|
|
130
|
+
"## Objetivo",
|
|
131
|
+
"",
|
|
132
|
+
objective,
|
|
133
|
+
"",
|
|
134
|
+
"## Fases",
|
|
135
|
+
"",
|
|
136
|
+
"1. Preparar PRD/plan maestro.",
|
|
137
|
+
"2. Ejecutar slices acotados con el orquestador.",
|
|
138
|
+
"3. Procesar reviews y esperar merge externo cuando corresponda.",
|
|
139
|
+
"4. Verificar criterios globales antes de cerrar.",
|
|
140
|
+
"",
|
|
141
|
+
].join("\n");
|
|
142
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { GoalRecord, GoalStatus, GoalStore, NextAction } from "./types.js";
|
|
4
|
+
import { assertSafeArtifactPath, createMasterArtifacts } from "./artifacts.js";
|
|
5
|
+
|
|
6
|
+
const COMMANDS = new Set(["status", "plan", "history", "pause", "resume", "cancel", "merged"]);
|
|
7
|
+
const EXPLICITLY_UNSUPPORTED = new Set(["quick", "work"]);
|
|
8
|
+
|
|
9
|
+
export interface GoalCommandHandlersOptions {
|
|
10
|
+
store: GoalStore;
|
|
11
|
+
project: string;
|
|
12
|
+
artifactsRootDir?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface GoalCommandResponse {
|
|
16
|
+
message: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface GoalCommandHandlers {
|
|
20
|
+
handleGoalCommand(input: string): GoalCommandResponse;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createGoalCommandHandlers(options: GoalCommandHandlersOptions): GoalCommandHandlers {
|
|
24
|
+
let currentGoalId: string | undefined;
|
|
25
|
+
|
|
26
|
+
const currentGoal = () => {
|
|
27
|
+
const byId = currentGoalId ? options.store.getGoal(currentGoalId) : undefined;
|
|
28
|
+
const active = byId ?? options.store.getCurrentGoal(options.project);
|
|
29
|
+
currentGoalId = active?.id ?? currentGoalId;
|
|
30
|
+
return active;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const requireCurrentGoal = () => {
|
|
34
|
+
const goal = currentGoal();
|
|
35
|
+
if (!goal) {
|
|
36
|
+
throw new Error("No active goal found. Start one with /goal <objective>.");
|
|
37
|
+
}
|
|
38
|
+
return goal;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
handleGoalCommand(rawInput: string): GoalCommandResponse {
|
|
43
|
+
const input = rawInput.trim();
|
|
44
|
+
const [firstToken = "", ...args] = input.split(/\s+/);
|
|
45
|
+
const firstLower = firstToken.toLowerCase();
|
|
46
|
+
const normalized = firstLower === "merged" ? "merged" : input.toLowerCase();
|
|
47
|
+
|
|
48
|
+
if (input.length === 0) {
|
|
49
|
+
throw new Error("Goal objective cannot be empty.");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (EXPLICITLY_UNSUPPORTED.has(normalized)) {
|
|
53
|
+
throw new Error("/goal quick and /goal work are not supported. Use /goal <objective> for large goals.");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!COMMANDS.has(normalized)) {
|
|
57
|
+
let artifactRootDir: string | undefined;
|
|
58
|
+
const goal = options.store.transaction(() => {
|
|
59
|
+
const createdGoal = options.store.createGoal({
|
|
60
|
+
objective: input,
|
|
61
|
+
project: options.project,
|
|
62
|
+
});
|
|
63
|
+
artifactRootDir = options.artifactsRootDir
|
|
64
|
+
? path.join(options.artifactsRootDir, createdGoal.id)
|
|
65
|
+
: undefined;
|
|
66
|
+
try {
|
|
67
|
+
if (artifactRootDir) {
|
|
68
|
+
createMasterArtifacts({
|
|
69
|
+
store: options.store,
|
|
70
|
+
goalId: createdGoal.id,
|
|
71
|
+
rootDir: artifactRootDir,
|
|
72
|
+
allowedRootDir: options.artifactsRootDir,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
options.store.appendEvent(createdGoal.id, {
|
|
76
|
+
type: "goal.created",
|
|
77
|
+
message: `Goal created: ${createdGoal.objective}`,
|
|
78
|
+
data: { objective: createdGoal.objective, project: createdGoal.project },
|
|
79
|
+
});
|
|
80
|
+
} catch (error) {
|
|
81
|
+
let cleanupFailure: string | undefined;
|
|
82
|
+
if (artifactRootDir && options.artifactsRootDir) {
|
|
83
|
+
cleanupFailure = cleanupBootstrappedArtifactDir(artifactRootDir, options.artifactsRootDir);
|
|
84
|
+
}
|
|
85
|
+
const rootCause = error instanceof Error ? error.message : String(error);
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Goal bootstrap failed: ${rootCause}${cleanupFailure ? ` Cleanup also failed: ${cleanupFailure}` : ""}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return createdGoal;
|
|
91
|
+
});
|
|
92
|
+
currentGoalId = goal.id;
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
message: `Goal created: ${goal.objective}\nStatus: ${goal.status}\nNext action: ${formatNextAction(options.store.nextAction(goal.id))}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (normalized === "status") return statusResponse(currentGoal(), options.store);
|
|
100
|
+
if (normalized === "plan") {
|
|
101
|
+
return planResponse(requireCurrentGoal(), options.store, options.artifactsRootDir);
|
|
102
|
+
}
|
|
103
|
+
if (normalized === "history") return historyResponse(requireCurrentGoal(), options.store);
|
|
104
|
+
|
|
105
|
+
const goal = requireCurrentGoal();
|
|
106
|
+
if (normalized === "pause") {
|
|
107
|
+
return transitionResponse(options.store, goal, "paused", "Goal paused by user.");
|
|
108
|
+
}
|
|
109
|
+
if (normalized === "resume") {
|
|
110
|
+
if (goal.status === "waiting_for_merge") {
|
|
111
|
+
throw new Error("Cannot resume while the goal is waiting for an external PR merge.");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const nextStatus = options.store.getOpenPullRequest(goal.id) ? "waiting_for_merge" : "active";
|
|
115
|
+
return transitionResponse(options.store, goal, nextStatus, "Goal resumed by user.");
|
|
116
|
+
}
|
|
117
|
+
if (normalized === "cancel") {
|
|
118
|
+
return transitionResponse(options.store, goal, "cancelled", "Goal cancelled by user.");
|
|
119
|
+
}
|
|
120
|
+
if (normalized === "merged") {
|
|
121
|
+
return mergedResponse(options.store, goal, args.join(" "));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
throw new Error(`Unsupported /goal command: ${input}`);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function statusResponse(goal: GoalRecord | undefined, store: GoalStore): GoalCommandResponse {
|
|
130
|
+
if (!goal) {
|
|
131
|
+
return { message: "No active goal. Start one with /goal <objective>." };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const lines = [
|
|
135
|
+
`Goal: ${goal.objective}`,
|
|
136
|
+
`Status: ${goal.status}`,
|
|
137
|
+
`Next action: ${formatNextAction(store.nextAction(goal.id))}`,
|
|
138
|
+
];
|
|
139
|
+
const issue = latestAutoContinueIssue(store, goal.id);
|
|
140
|
+
if (issue) lines.push(`Operational issue: ${issue.message}`);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
message: lines.join("\n"),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function planResponse(goal: GoalRecord, store: GoalStore, artifactsRootDir: string | undefined): GoalCommandResponse {
|
|
148
|
+
const plan = store.getArtifact(goal.id, "plan");
|
|
149
|
+
if (plan) {
|
|
150
|
+
if (!fs.existsSync(plan.path)) {
|
|
151
|
+
throw new Error(`Registered goal plan is not readable at ${plan.path}. Recreate or fix the artifact path.`);
|
|
152
|
+
}
|
|
153
|
+
if (artifactsRootDir) {
|
|
154
|
+
assertSafeArtifactPath(plan.path, artifactsRootDir, "Registered goal plan");
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
message: [`Plan for goal: ${goal.objective}`, fs.readFileSync(plan.path, "utf8")].join("\n\n"),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (artifactsRootDir) {
|
|
162
|
+
throw new Error("Goal master plan artifact is not registered. Recreate or repair the goal artifacts.");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
message: [
|
|
167
|
+
`Plan for goal: ${goal.objective}`,
|
|
168
|
+
"Phases: master PRD/plan generation, slice execution, PR review, waiting for merge, final verification.",
|
|
169
|
+
"Detailed master artifacts are created by the next Goal Mode slice.",
|
|
170
|
+
].join("\n"),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function cleanupBootstrappedArtifactDir(rootDir: string, allowedRootDir: string): string | undefined {
|
|
175
|
+
try {
|
|
176
|
+
assertSafeArtifactPath(rootDir, allowedRootDir, "Goal artifact cleanup path");
|
|
177
|
+
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
178
|
+
return undefined;
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return error instanceof Error ? error.message : String(error);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function mergedResponse(store: GoalStore, goal: GoalRecord, mergeCommit: string): GoalCommandResponse {
|
|
185
|
+
const pullRequest = store.getOpenPullRequest(goal.id);
|
|
186
|
+
if (!pullRequest) {
|
|
187
|
+
throw new Error("No open pull request is waiting for merge.");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const merged = store.recordPullRequestMerged(pullRequest.id, {
|
|
191
|
+
mergedAt: new Date().toISOString(),
|
|
192
|
+
mergeCommit: mergeCommit.trim() || "manual",
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
message: `Pull request #${merged.number} marked as merged. Goal status: ${store.getGoal(goal.id)?.status ?? "unknown"}`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function historyResponse(goal: GoalRecord, store: GoalStore): GoalCommandResponse {
|
|
201
|
+
const events = store.listEvents(goal.id);
|
|
202
|
+
const lines = events.length === 0
|
|
203
|
+
? ["No history events recorded yet."]
|
|
204
|
+
: events.map((event) => `- ${event.createdAt} ${event.type}: ${event.message}`);
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
message: [`History for goal: ${goal.objective}`, ...lines].join("\n"),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function transitionResponse(
|
|
212
|
+
store: GoalStore,
|
|
213
|
+
goal: GoalRecord,
|
|
214
|
+
status: GoalStatus,
|
|
215
|
+
reason: string,
|
|
216
|
+
): GoalCommandResponse {
|
|
217
|
+
const updated = store.transitionGoal(goal.id, status, { reason });
|
|
218
|
+
return {
|
|
219
|
+
message: `Goal ${updated.status}: ${updated.objective}`,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function latestAutoContinueIssue(store: GoalStore, goalId: string) {
|
|
224
|
+
const events = store.listEvents(goalId);
|
|
225
|
+
const currentStateSequence = Math.max(
|
|
226
|
+
0,
|
|
227
|
+
...events
|
|
228
|
+
.filter((event) => !event.type.startsWith("goal.auto_continue_"))
|
|
229
|
+
.map((event) => event.sequence),
|
|
230
|
+
);
|
|
231
|
+
return events
|
|
232
|
+
.filter((event) =>
|
|
233
|
+
(
|
|
234
|
+
event.type === "goal.auto_continue_unavailable" ||
|
|
235
|
+
event.type === "goal.auto_continue_failed" ||
|
|
236
|
+
event.type === "goal.auto_continue_skipped"
|
|
237
|
+
) &&
|
|
238
|
+
readEventStateSequence(event.data) === currentStateSequence,
|
|
239
|
+
)
|
|
240
|
+
.at(-1);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function readEventStateSequence(data: unknown): number | undefined {
|
|
244
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) return undefined;
|
|
245
|
+
const value = (data as { stateSequence?: unknown }).stateSequence;
|
|
246
|
+
return typeof value === "number" ? value : undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function formatNextAction(action: NextAction): string {
|
|
250
|
+
if (action.type === "wait_for_merge") {
|
|
251
|
+
return `waiting for external merge of ${action.pullRequestId}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return "continue";
|
|
255
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
|
|
5
|
+
type SqliteRow = Record<string, unknown>;
|
|
6
|
+
|
|
7
|
+
interface SqliteStatement {
|
|
8
|
+
run(...params: unknown[]): unknown;
|
|
9
|
+
get(...params: unknown[]): SqliteRow | undefined;
|
|
10
|
+
all(...params: unknown[]): SqliteRow[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SqliteDatabase {
|
|
14
|
+
exec(sql: string): void;
|
|
15
|
+
close(): void;
|
|
16
|
+
prepare?(sql: string): SqliteStatement;
|
|
17
|
+
query?(sql: string): SqliteStatement;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
|
|
22
|
+
function loadDatabaseCtor(): new (databasePath: string, options?: Record<string, unknown>) => SqliteDatabase {
|
|
23
|
+
if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") {
|
|
24
|
+
return require("bun:sqlite").Database;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return require("node:sqlite").DatabaseSync;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class GoalDb {
|
|
31
|
+
private readonly db: SqliteDatabase;
|
|
32
|
+
|
|
33
|
+
constructor(databasePath: string) {
|
|
34
|
+
fs.mkdirSync(path.dirname(databasePath), { recursive: true });
|
|
35
|
+
const Database = loadDatabaseCtor();
|
|
36
|
+
this.db = new Database(databasePath, { create: true });
|
|
37
|
+
this.exec("PRAGMA foreign_keys = ON;");
|
|
38
|
+
this.exec("PRAGMA busy_timeout = 5000;");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
exec(sql: string): void {
|
|
42
|
+
this.db.exec(sql);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
prepare(sql: string): SqliteStatement {
|
|
46
|
+
const statement = this.db.prepare?.(sql) ?? this.db.query?.(sql);
|
|
47
|
+
if (!statement) {
|
|
48
|
+
throw new Error("SQLite runtime does not expose prepare/query.");
|
|
49
|
+
}
|
|
50
|
+
return statement;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get(sql: string, ...params: unknown[]): SqliteRow | undefined {
|
|
54
|
+
return this.prepare(sql).get(...params);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
all(sql: string, ...params: unknown[]): SqliteRow[] {
|
|
58
|
+
return this.prepare(sql).all(...params);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
run(sql: string, ...params: unknown[]): unknown {
|
|
62
|
+
return this.prepare(sql).run(...params);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
close(): void {
|
|
66
|
+
this.db.close();
|
|
67
|
+
}
|
|
68
|
+
}
|