jorgex-stack 1.0.13 → 1.0.15

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/README.md CHANGED
@@ -71,7 +71,7 @@ Operational state:
71
71
 
72
72
  ## Status
73
73
 
74
- The CLI is complete and the real migration has been executed (F6); the stack is the only configuration source. Versions are published automatically to [npm](https://www.npmjs.com/package/jorgex-stack) according to the flow described in [Publishing](#publishing). The design, decisions (D1-D9), and roadmap are in [PRD.md](PRD.md).
74
+ The CLI is complete and the real migration has been executed; the stack is the only configuration source. Versions are published automatically to [npm](https://www.npmjs.com/package/jorgex-stack) according to the flow described in [Publishing](#publishing).
75
75
 
76
76
  ## Publishing
77
77
 
@@ -79,12 +79,10 @@ Releases are triggered by push/merge to `main` and GitHub Actions; there is also
79
79
 
80
80
  - **Automatic patch**: if the push to `main` contains publishable changes and the current `package.json` version already exists on npm, the workflow finds the first free patch (`x+1`, `x+2`, ...), commits `chore(release): bump version to v...`, and publishes. If tag `v<package.version>` already exists, it uses that point as the accumulated base; otherwise, it falls back to `github.event.before`. Obsolete runs are aborted after `git fetch origin main --tags` if `origin/main` no longer matches `GITHUB_SHA`.
81
81
  - **Manual recovery**: a manual run on `main` with `release_sha` publishes that SHA if it does not exist on npm yet, without bumping again; if the version already exists on npm but tag `v<version>` is missing, the workflow fails and forces a rerun with `release_sha=<published sha>` to avoid tagging `origin/main`. `release_sha` must be a full 40-hex SHA and belong to `main`; mutable refs (`main`, tags, `main~1`) are rejected. If you do not pass `release_sha`, `validate` resolves `origin/main` once, exposes it as `target_sha`, and `bump` uses that validated SHA. Recovery does not bypass the `.github/workflows/*` guard: if the diff mixes workflows with publishable changes, split the release or perform the tag/publish manually with elevated permissions. If there is no reachable previous release tag to reconstruct the range, the workflow fails closed and requires manual intervention.
82
- - **No release**: changes only in `work/`, `worktrees/`, tests, or files not listed as publishable (`src/`, `stack/`, `upstreams.json`, `package.json`, `pnpm-lock.yaml`, `tsconfig.json`, `tsup.config.ts`, `README.md`, `PRD.md`) do not create a release.
82
+ - **No release**: changes only in `work/`, `worktrees/`, tests, or docs (`README.md`, `docs/`) do not create a release. The publishable set that does trigger one is `src/`, `stack/`, `upstreams.json`, `package.json`, `pnpm-lock.yaml`, `tsconfig.json`, and `tsup.config.ts`.
83
83
  - **Manual minor and major**: explicit bump in `package.json` in the PR (the workflow detects that the next patch already exists on npm and requires the bump).
84
84
  - **OIDC / trusted publishing**: the publishing job uses `id-token: write` and `setup-node` `registry-url`; the bump/push job only has `contents: write`; `tag-release` only writes `contents` and does not use OIDC. There is no `NPM_TOKEN` or `NODE_AUTH_TOKEN` in any secret. `tag-release` only runs if `publish` was `success` or `skipped` with `tag_needed=true`, and keeps its SHA validation as the final defense. The only exception to the "always pnpm" rule is `npm pack --dry-run --ignore-scripts` and `npm publish --ignore-scripts --provenance` in the final step, for registry compatibility and hardening.
85
85
 
86
- Design details are in [PRD §7.6](PRD.md).
87
-
88
86
  ## Development
89
87
 
90
88
  Requirements: Node >= 22.5 and pnpm (never npm). Goal Mode uses `node:sqlite` in tests/Node CLI and OpenCode uses `bun:sqlite` at runtime.
package/dist/cli.js CHANGED
@@ -1323,6 +1323,7 @@ function planPlugins(adapter, ctx) {
1323
1323
  );
1324
1324
  content = content.replace(/"\{\{ENGRAM_PROTOCOL\}\}"/g, JSON.stringify(protocol));
1325
1325
  }
1326
+ content = content.replace(/(from\s+["'])(\.{1,2}\/[^"']+?)\.js(["'])/g, "$1$2.ts$3");
1326
1327
  if (content === raw) return { kind: "copy", source: sourceFile, target };
1327
1328
  return { kind: "write", target, content };
1328
1329
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jorgex-stack",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
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",
@@ -116,7 +116,9 @@ Every subagent ends with a **Result contract** (Status / Delegations / Risks). P
116
116
 
117
117
  ### Worktree
118
118
 
119
- Before the first task, create a git worktree for this work (branch = canonical name) and run the ENTIRE execution inside it — implementation, tests, commits and pushes happen there, never on the user's main checkout.
119
+ Before the first task, create a git worktree for this work and run the ENTIRE execution inside it — implementation, tests, commits and pushes happen there, never on the user's main checkout.
120
+
121
+ Canonical location is mandatory: resolve the project root with `git rev-parse --show-toplevel`, ensure `worktrees/` is ignored in the repo-local `.git/info/exclude`, create `worktrees/` inside that root if needed, and create the worktree at `<project-root>/worktrees/<canonical-name>` (branch = canonical name). Do not create worktrees next to the repo, in the repo root, under `work/`, or in any external temp/shared folder.
120
122
 
121
123
  Every delegation prompt must state the worktree path as the ONLY allowed write root. After each writer subagent finishes, verify the user's main checkout is still clean (`git status` there); if the subagent wrote outside the worktree, STOP, move those changes into the worktree (patch/apply) and restore the main checkout before continuing. Subagent obedience is not a safety boundary — this check is.
122
124
 
@@ -16,12 +16,30 @@
16
16
 
17
17
  import type { Plugin } from "@opencode-ai/plugin"
18
18
 
19
+ declare const Bun: {
20
+ which?: (bin: string) => string | null
21
+ spawnSync: (args: string[]) => { exitCode: number; stdout?: { toString(): string } | string }
22
+ spawn: (args: string[], options?: Record<string, unknown>) => unknown
23
+ file: (path: string) => { exists: () => Promise<boolean> }
24
+ }
25
+
19
26
  // ─── Configuration ───────────────────────────────────────────────────────────
20
27
 
21
28
  const ENGRAM_PORT = parseInt(process.env.ENGRAM_PORT ?? "7437")
22
29
  const ENGRAM_URL = `http://127.0.0.1:${ENGRAM_PORT}`
23
30
  // "{{ENGRAM_BIN}}" lo resuelve el instalador con el binario detectado (D7).
24
- const ENGRAM_BIN = process.env.ENGRAM_BIN ?? Bun.which("engram") ?? "{{ENGRAM_BIN}}"
31
+ const ENGRAM_BIN = "{{ENGRAM_BIN}}"
32
+
33
+ export function resolveEngramBin(installerBin = ENGRAM_BIN): string {
34
+ const envBin = process.env.ENGRAM_BIN
35
+ if (envBin) return envBin
36
+
37
+ const bun = globalThis as typeof globalThis & { Bun?: { which?: (bin: string) => string | null } }
38
+ const bunBin = bun.Bun?.which?.("engram")
39
+ if (bunBin) return bunBin
40
+
41
+ return installerBin !== "{{ENGRAM_BIN}}" ? installerBin : "engram"
42
+ }
25
43
 
26
44
  // Engram's own MCP tools — don't count these as "tool calls" for session stats
27
45
  const ENGRAM_TOOLS = new Set([
@@ -60,6 +78,7 @@ async function engramFetch(
60
78
  headers: opts.body ? { "Content-Type": "application/json" } : undefined,
61
79
  body: opts.body ? JSON.stringify(opts.body) : undefined,
62
80
  })
81
+ if (!res.ok) return null
63
82
  return await res.json()
64
83
  } catch {
65
84
  // Engram server not running — silently fail
@@ -126,6 +145,7 @@ function stripPrivateTags(str: string): string {
126
145
  export const Engram: Plugin = async (ctx) => {
127
146
  const oldProject = ctx.directory.split(/[\\/]/).pop() ?? "unknown"
128
147
  const project = extractProjectName(ctx.directory)
148
+ const engramBin = resolveEngramBin()
129
149
 
130
150
  // Track tool counts per session (in-memory only, not critical)
131
151
  const toolCounts = new Map<string, number>()
@@ -145,12 +165,12 @@ export const Engram: Plugin = async (ctx) => {
145
165
  *
146
166
  * Silently skips sub-agent sessions (tracked in `subAgentSessions`).
147
167
  */
148
- async function ensureSession(sessionId: string): Promise<void> {
149
- if (!sessionId || knownSessions.has(sessionId)) return
168
+ async function ensureSession(sessionId: string): Promise<boolean> {
169
+ if (!sessionId) return false
170
+ if (knownSessions.has(sessionId)) return true
150
171
  // Do not register sub-agent sessions in Engram (issue #116).
151
- if (subAgentSessions.has(sessionId)) return
152
- knownSessions.add(sessionId)
153
- await engramFetch("/sessions", {
172
+ if (subAgentSessions.has(sessionId)) return false
173
+ const session = await engramFetch("/sessions", {
154
174
  method: "POST",
155
175
  body: {
156
176
  id: sessionId,
@@ -158,13 +178,18 @@ export const Engram: Plugin = async (ctx) => {
158
178
  directory: ctx.directory,
159
179
  },
160
180
  })
181
+
182
+ if (session === null) return false
183
+
184
+ knownSessions.add(sessionId)
185
+ return true
161
186
  }
162
187
 
163
188
  // Try to start engram server if not running
164
189
  const running = await isEngramRunning()
165
190
  if (!running) {
166
191
  try {
167
- Bun.spawn([ENGRAM_BIN, "serve"], {
192
+ Bun.spawn([engramBin, "serve"], {
168
193
  stdout: "ignore",
169
194
  stderr: "ignore",
170
195
  stdin: "ignore",
@@ -192,7 +217,7 @@ export const Engram: Plugin = async (ctx) => {
192
217
  const manifestFile = `${ctx.directory}/.engram/manifest.json`
193
218
  const file = Bun.file(manifestFile)
194
219
  if (await file.exists()) {
195
- Bun.spawn([ENGRAM_BIN, "sync", "--import"], {
220
+ Bun.spawn([engramBin, "sync", "--import"], {
196
221
  cwd: ctx.directory,
197
222
  stdout: "ignore",
198
223
  stderr: "ignore",
@@ -206,7 +231,7 @@ export const Engram: Plugin = async (ctx) => {
206
231
  return {
207
232
  // ─── Event Listeners ───────────────────────────────────────────
208
233
 
209
- event: async ({ event }) => {
234
+ event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
210
235
  // --- Session Created ---
211
236
  if (event.type === "session.created") {
212
237
  // Bug fix (#116): session data is nested under event.properties.info,
@@ -254,7 +279,7 @@ export const Engram: Plugin = async (ctx) => {
254
279
  // output.message is typed as UserMessage (role:"user" already guaranteed).
255
280
  // output.parts contains TextPart[] with the actual message text.
256
281
 
257
- "chat.message": async (input, output) => {
282
+ "chat.message": async (input: any, output: any) => {
258
283
  // Skip sub-agent sessions — they inflate session counts (issue #116)
259
284
  if (subAgentSessions.has(input.sessionID)) return
260
285
 
@@ -262,8 +287,8 @@ export const Engram: Plugin = async (ctx) => {
262
287
 
263
288
  // Extract text from parts (type:"text")
264
289
  const content = output.parts
265
- .filter((p) => p.type === "text")
266
- .map((p) => (p as any).text ?? "")
290
+ .filter((p: any) => p.type === "text")
291
+ .map((p: any) => p.text ?? "")
267
292
  .join("\n")
268
293
  .trim()
269
294
 
@@ -276,15 +301,17 @@ export const Engram: Plugin = async (ctx) => {
276
301
 
277
302
  // Only capture non-trivial prompts (>10 chars)
278
303
  if (finalContent.length > 10) {
279
- await ensureSession(sessionId)
280
- await engramFetch("/prompts", {
281
- method: "POST",
282
- body: {
283
- session_id: sessionId,
284
- content: stripPrivateTags(truncate(finalContent, 2000)),
285
- project,
286
- },
287
- })
304
+ const sessionReady = await ensureSession(sessionId)
305
+ if (sessionReady) {
306
+ await engramFetch("/prompts", {
307
+ method: "POST",
308
+ body: {
309
+ session_id: sessionId,
310
+ content: stripPrivateTags(truncate(finalContent, 2000)),
311
+ project,
312
+ },
313
+ })
314
+ }
288
315
  }
289
316
  },
290
317
 
@@ -294,13 +321,13 @@ export const Engram: Plugin = async (ctx) => {
294
321
  // Passive capture: when a Task tool completes, POST its output to
295
322
  // the passive capture endpoint so the server extracts learnings.
296
323
 
297
- "tool.execute.after": async (input, output) => {
324
+ "tool.execute.after": async (input: any, output: any) => {
298
325
  if (ENGRAM_TOOLS.has(input.tool.toLowerCase())) return
299
326
 
300
327
  // input.sessionID comes from OpenCode — always available
301
328
  const sessionId = input.sessionID
302
- if (sessionId) {
303
- await ensureSession(sessionId)
329
+ const sessionReady = sessionId ? await ensureSession(sessionId) : false
330
+ if (sessionReady && sessionId) {
304
331
  toolCounts.set(sessionId, (toolCounts.get(sessionId) ?? 0) + 1)
305
332
  }
306
333
 
@@ -308,7 +335,7 @@ export const Engram: Plugin = async (ctx) => {
308
335
  // (OpenCode reports the tool name in lowercase: "task")
309
336
  if (input.tool.toLowerCase() === "task" && output && sessionId) {
310
337
  const text = typeof output === "string" ? output : JSON.stringify(output)
311
- if (text.length > 50) {
338
+ if (text.length > 50 && sessionReady) {
312
339
  await engramFetch("/observations/passive", {
313
340
  method: "POST",
314
341
  body: {
@@ -332,7 +359,7 @@ export const Engram: Plugin = async (ctx) => {
332
359
  // block at the beginning. By concatenating, we avoid adding extra system
333
360
  // messages that would break these models. See: GitHub issue #23.
334
361
 
335
- "experimental.chat.system.transform": async (_input, output) => {
362
+ "experimental.chat.system.transform": async (_input: any, output: any) => {
336
363
  if (output.system.length > 0) {
337
364
  output.system[output.system.length - 1] += "\n\n" + MEMORY_INSTRUCTIONS
338
365
  } else {
@@ -348,7 +375,7 @@ export const Engram: Plugin = async (ctx) => {
348
375
  // 2. Inject context from previous sessions into the compaction prompt
349
376
  // 3. Tell the compressor to remind the new agent to save memories
350
377
 
351
- "experimental.session.compacting": async (input, output) => {
378
+ "experimental.session.compacting": async (input: any, output: any) => {
352
379
  if (input.sessionID) {
353
380
  await ensureSession(input.sessionID)
354
381
  }
@@ -1,4 +1,5 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin";
2
+ import path from "node:path";
2
3
 
3
4
  interface WorktreePluginConfig {
4
5
  setupScript?: string;
@@ -19,12 +20,22 @@ interface ScriptResult {
19
20
  const isAbsolutePath = (value: string) =>
20
21
  /^[a-zA-Z]:[\\/]/.test(value) || value.startsWith("/");
21
22
 
23
+ const isWindowsPath = (value: string) => /^[a-zA-Z]:[\\/]/.test(value);
24
+
25
+ const toSlashes = (value: string) => value.replace(/\\/g, "/");
26
+
22
27
  const joinProjectPath = (directory: string, target: string) => {
23
- const normalizedDirectory = directory.replace(/[\\/]+$/, "");
24
- const normalizedTarget = target.replace(/^[\\/]+/, "").replace(/\\/g, "/");
28
+ const normalizedDirectory = toSlashes(directory).replace(/[\\/]+$/, "");
29
+ const normalizedTarget = toSlashes(target).replace(/^[\\/]+/, "");
25
30
  return `${normalizedDirectory}/${normalizedTarget}`;
26
31
  };
27
32
 
33
+ const resolvePath = (base: string, target: string) => {
34
+ const api = isWindowsPath(base) || isWindowsPath(target) ? path.win32 : path.posix;
35
+ const resolved = api.resolve(base, target);
36
+ return toSlashes(resolved);
37
+ };
38
+
28
39
  const resolveProjectPath = (directory: string, target?: string) => {
29
40
  if (!target) return undefined;
30
41
  return isAbsolutePath(target) ? target : joinProjectPath(directory, target);
@@ -250,10 +261,28 @@ const parseWorktreePath = (command: string) => {
250
261
  };
251
262
 
252
263
  const getWorktreeName = (worktreePath: string) => {
253
- const segments = worktreePath.replace(/\\/g, "/").split("/").filter(Boolean);
264
+ const segments = toSlashes(worktreePath).split("/").filter(Boolean);
254
265
  return segments[segments.length - 1] || null;
255
266
  };
256
267
 
268
+ const normalizePath = (value: string) =>
269
+ toSlashes(value).replace(/\/+$/, "");
270
+
271
+ const samePath = (left: string, right: string) => {
272
+ const normalizedLeft = normalizePath(left);
273
+ const normalizedRight = normalizePath(right);
274
+ if (isWindowsPath(normalizedLeft) || isWindowsPath(normalizedRight)) {
275
+ return normalizedLeft.toLowerCase() === normalizedRight.toLowerCase();
276
+ }
277
+ return normalizedLeft === normalizedRight;
278
+ };
279
+
280
+ const getCommandCwd = (args: Record<string, unknown>, directory: string) => {
281
+ const cwd = args.workdir || args.cwd;
282
+ if (typeof cwd !== "string" || cwd.length === 0) return directory;
283
+ return isAbsolutePath(cwd) ? cwd : resolvePath(directory, cwd);
284
+ };
285
+
257
286
  const replaceToken = (value: string, token: string, replacement: string) =>
258
287
  value.split(token).join(replacement);
259
288
 
@@ -313,14 +342,11 @@ export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
313
342
  const args = input.args || {};
314
343
  const command = args.command || "";
315
344
  const commandLower = command.toLowerCase();
316
- const pathContains = (
317
- config.pathContains || "worktrees/"
345
+ const pathContains = toSlashes(
346
+ config.pathContains || "worktrees/",
318
347
  ).toLowerCase();
319
348
 
320
- if (
321
- !commandLower.includes("git worktree add") ||
322
- !commandLower.includes(pathContains)
323
- ) {
349
+ if (!commandLower.includes("git worktree add")) {
324
350
  return;
325
351
  }
326
352
 
@@ -334,9 +360,25 @@ export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
334
360
 
335
361
  const gitRoot = await $`git rev-parse --show-toplevel`.text();
336
362
  const projectRoot = String(gitRoot).trim().replace(/\\/g, "/");
337
- const absoluteWorktreePath = isAbsolutePath(parsedWorktreePath)
338
- ? parsedWorktreePath.replace(/\\/g, "/")
339
- : joinProjectPath(projectRoot, parsedWorktreePath);
363
+ const commandCwd = getCommandCwd(args, directory);
364
+ const absoluteWorktreePath = resolvePath(commandCwd, parsedWorktreePath);
365
+ const expectedWorktreePath = joinProjectPath(
366
+ projectRoot,
367
+ `worktrees/${worktreeName}`,
368
+ );
369
+
370
+ if (!samePath(absoluteWorktreePath, expectedWorktreePath)) {
371
+ appendToolOutput(output, [
372
+ `Worktree path is not canonical: ${absoluteWorktreePath}`,
373
+ `Use the project-local path instead: ${expectedWorktreePath}`,
374
+ "Canonical rule: <project-root>/worktrees/<canonical-name>.",
375
+ ]);
376
+ return;
377
+ }
378
+
379
+ if (!normalizePath(absoluteWorktreePath).toLowerCase().includes(pathContains)) {
380
+ return;
381
+ }
340
382
 
341
383
  const setupScript = resolveProjectPath(directory, config.setupScript);
342
384
  if (setupScript) {
@@ -33,7 +33,8 @@ Every piece of work gets a **canonical kebab-case name** when it starts (e.g. `c
33
33
 
34
34
  ## Executing
35
35
 
36
- - Execution happens inside a git worktree created for the work (branch = canonical name); the user's main checkout stays untouched until merge.
36
+ - Execution happens inside a git worktree created for the work; the user's main checkout stays untouched until merge.
37
+ - Worktree path is fixed: first resolve the project root with `git rev-parse --show-toplevel`, ensure `worktrees/` is ignored in the repo-local `.git/info/exclude`, then create/use `<project-root>/worktrees/<canonical-name>` (branch = canonical name). Never place worktrees in the repo root, next to the repo, under `work/`, or outside the project.
37
38
  - Delegation handoff: the subagent receives its **topic_key + task title**, never the task content inline. It retrieves the spec itself (`mem_search` → `mem_get_observation`).
38
39
  - The subagent saves its phase outcome under the topic_key the orchestrator gave it (`work/{name}/{phase}`) BEFORE its final report.
39
40
  - Task status lives ONLY in the plan.md table: flip it (⬜ → ✅) with a surgical edit when the task closes. Do not mirror statuses into memory, and do not re-read the whole plan after every task — it is already in context; re-read it on resume.
@@ -91,6 +91,7 @@ docs/
91
91
  Every piece of information about a piece of work has exactly ONE home — never two. The `work-lifecycle` skill is the single source of this flow.
92
92
 
93
93
  - In-progress work lives in `work/{name}/` (gitignored): `PRD.md` + `plan.md`. plan.md is the ONLY task status board — update statuses with surgical edits. An empty `work/` means nothing is half-done.
94
+ - Execution worktrees always live inside the current project's root under `worktrees/{name}`. Resolve the root with `git rev-parse --show-toplevel`, ensure `worktrees/` is ignored in the repo-local `.git/info/exclude`, then create/use `<project-root>/worktrees/<canonical-name>`; never create worktrees next to the repo, in the repo root, under `work/`, or in external temp/shared folders.
94
95
  - Full task specs, phase outcomes and history live in Engram: `work/{name}/task/{NN}`, `work/{name}/{phase}`, `work/{name}/done`. Subagents receive a topic_key + title, never the task content inline.
95
96
  - Pending work: the project's single `work/backlog` topic_key (one upserted list — never one key per idea), or issues (`to-issues`) if the project uses a tracker. Never a TODOs folder.
96
97
  - On close: save the outcome under `work/{name}/done`, move the PRD to the project's docs only if it has lasting value, then delete `work/{name}/`. History is memory + git — no archive folders.