jorgex-stack 1.9.45 → 1.9.47
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 +6 -4
- package/dist/cli.js +56 -9
- package/package.json +1 -1
- package/stack/plugins/opencode/worktree.ts +418 -95
package/README.md
CHANGED
|
@@ -116,7 +116,7 @@ Programmatic mode does **not** provide:
|
|
|
116
116
|
|
|
117
117
|
El canon de Stack y el paquete Pi adoptado mantienen una snapshot de 17 árboles de skills con 89 archivos de skill. La identidad, procedencia e integridad del paquete adoptado son autoritativas en `src/lib/pi-runtime-pin.json`.
|
|
118
118
|
|
|
119
|
-
Pi combines the frozen **snapshot v2** package with a Stack-owned shared projection. The version references that follow describe historical Stack/Pi transitions, not the current pin. The current pin
|
|
119
|
+
Pi combines the frozen **snapshot v2** package with a Stack-owned shared projection. The version references that follow describe historical Stack/Pi transitions, not the current pin. The current pin is the published `jorgex-pi@0.8.23` artifact. Package integrity and lifecycle are maintained in [docs/references/pi-runtime.md](docs/references/pi-runtime.md) and `src/lib/pi-runtime-pin.json`; this README does not imply a future Stack release.
|
|
120
120
|
|
|
121
121
|
The following command block is retained as historical reference for that transition:
|
|
122
122
|
|
|
@@ -128,11 +128,13 @@ pnpm dlx jorgex-stack@1.9.7 sync --agents pi
|
|
|
128
128
|
pnpm dlx jorgex-stack@1.9.7 uninstall --agents pi
|
|
129
129
|
```
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
For the current Pi `0.8.23` pin, package identity and runner validation precede projection; the exact pending initialization diagnostic remains provisional until projection and final `sync` complete.
|
|
132
132
|
|
|
133
|
-
The
|
|
133
|
+
Stack downloads the frozen registry tarball, verifies its exact size plus SHA-256/SHA-512, backs up Pi's `settings.json`, and only then asks Pi to install that local file. The exact artifact and integrity values are authoritative in `src/lib/pi-runtime-pin.json`, while the lifecycle remains authoritative in `src/lib/pi-runtime.ts`. Pi's own package-manager invocation is the narrow runtime exception to the repository's pnpm-only rule; the Stack lifecycle never launches npm directly. A managed install then runs `package install → projection install → package sync`: after the package identity and runner are validated, Stack projects the shared resources into Pi and invokes Pi's native sync to complete initialization. The projection includes marked `jorgex:system-prompt` and `jorgex:engram-protocol` sections in `~/.pi/agent/AGENTS.md`, canonical skills under `~/.agents/skills`, and `~/.pi/agent/prompts/lean-audit.md`. Pi 0.8.23 registers Context7 through an isolated in-memory HTTP bridge during bootstrap; `available` means configuration permits registration and does not imply an HTTP handshake. A conflict preserves the existing MCP file and blocks managed activation. No MCP configuration or credentials are written. For the file runtimes, Context7, Playwright and DevTools use independent managed sections. The global Playwright package and Chromium cache are shared by the machine; `--playwright-runtimes` controls which runtime receives the guide. The adopted Pi package implements and tests `playwright-handoff-v1` through `PI_CODING_AGENT_DIR/jorgex-pi/playwright.v1.json`. Historical package entries do not describe the current pin. Filters are applied only after this projection exists, so the package does not duplicate shared resources. Package ownership is recorded separately in `~/.jorgex-stack/pi-receipt.json`; projection ownership is recorded in `~/.jorgex-stack/pi-projection-receipt.json`. Package receipts reject manual, duplicate, divergent, partial, corrupt, copied-to-another-scope, or unknown-history state. Projection cleanup requires an exact scope-bound ownership receipt; DevTools conflicts preserve the handoff for review.
|
|
134
134
|
|
|
135
|
-
|
|
135
|
+
The static `AGENTS.md` projected by Stack does not include Context7; Pi's native bootstrap adds that section after registering the isolated bridge. Pi never writes, owns or removes MCP files. In Claude Code, Codex and OpenCode, a compatible existing Context7 entry is preserved and removed only with explicit canonical Stack ownership. During package installation only, `initialization-diagnostics-v1` permits the exact pending `doctor` envelope described in [the Pi runtime reference](docs/references/pi-runtime.md); it is provisional and does not make Pi healthy. Projection and final `sync` must still complete, and every other unhealthy or malformed result remains blocked.
|
|
136
|
+
|
|
137
|
+
Pi 0.8.23 also seeds `theme=JorgeX`, `quietStartup=true`, and `hideThinkingBlock=true` only for missing fields during first initialization. User changes and deletions are preserved without reseeding. `hideThinkingBlock` changes presentation only; `defaultThinkingLevel` and reasoning remain unchanged. Pi owns only the fields it creates and records in its lifecycle receipt; pre-existing user settings remain unowned. The JorgeX header preserves builtin and custom commands. `Ctrl+O` remains Pi's native tool presentation, with `NO_COLOR` and `JORGEX_PI_MOTION` behavior unchanged; Stack adds no renderer.
|
|
136
138
|
|
|
137
139
|
Historically, the published Pi 0.8.0 direct-package snapshot added `work-audit`: the snapshot grew from **17 to 18 skill trees** (96 to 97 files), and the active runtime allowlist grew from **16 to 17 skills**. The historical Pi 0.8.5 snapshot reference records 18 skill trees and 98 files; it does not describe the adopted pin. Reference F2-A is included while the private F1 skills remain preserved. `playwright-cli` remains in the snapshot but inactive because browser automation is a separate opt-in integration.
|
|
138
140
|
|
package/dist/cli.js
CHANGED
|
@@ -6146,16 +6146,16 @@ import { createHash as createHash2 } from "crypto";
|
|
|
6146
6146
|
var pi_runtime_pin_default = {
|
|
6147
6147
|
package: {
|
|
6148
6148
|
name: "jorgex-pi",
|
|
6149
|
-
version: "0.8.
|
|
6150
|
-
source: "npm:jorgex-pi@0.8.
|
|
6149
|
+
version: "0.8.23",
|
|
6150
|
+
source: "npm:jorgex-pi@0.8.23"
|
|
6151
6151
|
},
|
|
6152
6152
|
provenance: {
|
|
6153
|
-
commit: "
|
|
6153
|
+
commit: "ebb5e6e18e8e9297ffbf7bd53cf6785b952474b9"
|
|
6154
6154
|
},
|
|
6155
6155
|
tarball: {
|
|
6156
|
-
bytes:
|
|
6157
|
-
sha256: "
|
|
6158
|
-
sha512: "
|
|
6156
|
+
bytes: 89140657,
|
|
6157
|
+
sha256: "cbe75c7975353c6ed3123a9bd9c3395e18263a49f33f80a607123392ba27a8fd",
|
|
6158
|
+
sha512: "bb6dd4075a25818f301ed73d6b7b1432beca635fb6c130c0b0543844d086475640c41fa1e1e68035f3df97ad3cee52e5de8917b05fe01d7483875ae934e3374d"
|
|
6159
6159
|
}
|
|
6160
6160
|
};
|
|
6161
6161
|
|
|
@@ -6187,7 +6187,8 @@ var PI_RUNTIME_CANDIDATE = {
|
|
|
6187
6187
|
"tui-branding-v1",
|
|
6188
6188
|
"managed-primary-model-v1",
|
|
6189
6189
|
"quality-receipt-contract-v1",
|
|
6190
|
-
"quality-capabilities-contract-v1"
|
|
6190
|
+
"quality-capabilities-contract-v1",
|
|
6191
|
+
"initialization-diagnostics-v1"
|
|
6191
6192
|
],
|
|
6192
6193
|
runner: {
|
|
6193
6194
|
bin: "jorgex-pi",
|
|
@@ -6338,6 +6339,49 @@ function healthyDoctor(stdout, stderr, packageRunner, candidate) {
|
|
|
6338
6339
|
return false;
|
|
6339
6340
|
}
|
|
6340
6341
|
}
|
|
6342
|
+
function provisionalPendingDoctor(exitCode, stdout, stderr, packageRunner, candidate) {
|
|
6343
|
+
const capabilities = candidate.capabilities ?? PI_RUNTIME_CANDIDATE.contract.capabilities;
|
|
6344
|
+
if (!capabilities.includes("initialization-diagnostics-v1")) return false;
|
|
6345
|
+
if (exitCode !== 1 || stderr !== "" || !stdout.endsWith("\n") || stdout.slice(0, -1).includes("\n")) return false;
|
|
6346
|
+
try {
|
|
6347
|
+
const record = JSON.parse(stdout.slice(0, -1));
|
|
6348
|
+
if (record === null || typeof record !== "object" || Array.isArray(record)) return false;
|
|
6349
|
+
if (JSON.stringify(Object.keys(record).sort()) !== JSON.stringify(["command", "error", "ok", "package", "result", "schemaVersion"])) return false;
|
|
6350
|
+
if (Reflect.get(record, "schemaVersion") !== 1) return false;
|
|
6351
|
+
if (Reflect.get(record, "command") !== "doctor") return false;
|
|
6352
|
+
if (Reflect.get(record, "ok") !== false) return false;
|
|
6353
|
+
const packageValue = Reflect.get(record, "package");
|
|
6354
|
+
if (packageValue === null || typeof packageValue !== "object" || Array.isArray(packageValue)) return false;
|
|
6355
|
+
if (JSON.stringify(Object.keys(packageValue).sort()) !== JSON.stringify(["name", "root", "version"])) return false;
|
|
6356
|
+
if (Reflect.get(packageValue, "name") !== "jorgex-pi") return false;
|
|
6357
|
+
if (Reflect.get(packageValue, "version") !== (candidate.package?.version ?? /^npm:jorgex-pi@([^\s]+)$/.exec(candidate.source)?.[1])) return false;
|
|
6358
|
+
if (path33.resolve(packageRunner) !== path33.resolve(String(Reflect.get(packageValue, "root")), "bin", "jorgex-pi.mjs")) return false;
|
|
6359
|
+
const result = Reflect.get(record, "result");
|
|
6360
|
+
if (result === null || typeof result !== "object" || Array.isArray(result)) return false;
|
|
6361
|
+
if (JSON.stringify(Object.keys(result).sort()) !== JSON.stringify(["checks", "healthy"])) return false;
|
|
6362
|
+
if (Reflect.get(result, "healthy") !== false) return false;
|
|
6363
|
+
const checks = Reflect.get(result, "checks");
|
|
6364
|
+
if (!Array.isArray(checks) || checks.length !== 5) return false;
|
|
6365
|
+
const expectedIds = ["package", "engram", "context7", "permissions", "experience"];
|
|
6366
|
+
for (let index = 0; index < 5; index++) {
|
|
6367
|
+
const check = checks[index];
|
|
6368
|
+
if (check === null || typeof check !== "object" || Array.isArray(check)) return false;
|
|
6369
|
+
if (JSON.stringify(Object.keys(check).sort()) !== JSON.stringify(["id", "status"])) return false;
|
|
6370
|
+
if (Reflect.get(check, "id") !== expectedIds[index]) return false;
|
|
6371
|
+
const status = Reflect.get(check, "status");
|
|
6372
|
+
if (index < 3) {
|
|
6373
|
+
if (status !== "ok") return false;
|
|
6374
|
+
} else if (status !== "ok" && status !== "error") return false;
|
|
6375
|
+
}
|
|
6376
|
+
if (Reflect.get(checks[3], "status") === "ok" && Reflect.get(checks[4], "status") === "ok") return false;
|
|
6377
|
+
const errorValue = Reflect.get(record, "error");
|
|
6378
|
+
if (errorValue === null || typeof errorValue !== "object" || Array.isArray(errorValue)) return false;
|
|
6379
|
+
if (JSON.stringify(Object.keys(errorValue).sort()) !== JSON.stringify(["code", "message", "phase", "remedy"])) return false;
|
|
6380
|
+
return Reflect.get(errorValue, "phase") === "initialization" && Reflect.get(errorValue, "code") === "INITIALIZATION_REQUIRED" && Reflect.get(errorValue, "message") === "Pi initialization is pending: run sync to complete first initialization." && Reflect.get(errorValue, "remedy") === "Run jorgex-pi sync --json and retry.";
|
|
6381
|
+
} catch {
|
|
6382
|
+
return false;
|
|
6383
|
+
}
|
|
6384
|
+
}
|
|
6341
6385
|
function installPiFromVerifiedTarball(input, deps) {
|
|
6342
6386
|
const paths = input.targetDir === void 0 ? userPaths(input.engramBin, input.piExecutable) : targetPaths(input.targetDir, input.engramBin, input.piExecutable);
|
|
6343
6387
|
const destination = input.targetDir === void 0 ? path33.join(dataDir(), "packages", `jorgex-pi-${PI_RUNTIME_CANDIDATE.package.version}.tgz`) : path33.join(path33.resolve(input.targetDir), "downloads", `jorgex-pi-${PI_RUNTIME_CANDIDATE.package.version}.tgz`);
|
|
@@ -6368,7 +6412,9 @@ function installPiFromVerifiedTarball(input, deps) {
|
|
|
6368
6412
|
args: [paths.packageRunner, "doctor", "--json"],
|
|
6369
6413
|
environment: paths.environment
|
|
6370
6414
|
});
|
|
6371
|
-
|
|
6415
|
+
const healthy = doctor.exitCode === 0 && healthyDoctor(doctor.stdout, doctor.stderr, paths.packageRunner, input.candidate);
|
|
6416
|
+
const pending = provisionalPendingDoctor(doctor.exitCode, doctor.stdout, doctor.stderr, paths.packageRunner, input.candidate);
|
|
6417
|
+
if (!healthy && !pending) {
|
|
6372
6418
|
return { kind: "blocked", reason: "runner-unhealthy" };
|
|
6373
6419
|
}
|
|
6374
6420
|
const receipt = flatCandidateReceipt(input.candidate, scope, "installed", input.engramBin);
|
|
@@ -6680,7 +6726,8 @@ async function runPiRuntimeSystem(input) {
|
|
|
6680
6726
|
source: PI_RUNTIME_CANDIDATE.package.source,
|
|
6681
6727
|
...PI_RUNTIME_CANDIDATE.tarball,
|
|
6682
6728
|
package: PI_RUNTIME_CANDIDATE.package,
|
|
6683
|
-
provenance: PI_RUNTIME_CANDIDATE.provenance
|
|
6729
|
+
provenance: PI_RUNTIME_CANDIDATE.provenance,
|
|
6730
|
+
capabilities: PI_RUNTIME_CANDIDATE.contract.capabilities
|
|
6684
6731
|
}
|
|
6685
6732
|
}, {
|
|
6686
6733
|
download: () => artifact,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jorgex-stack",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.47",
|
|
4
4
|
"description": "Harness multi-agente portable: instala la config JorgeX (agentes, skills, hooks, Engram, MCPs) en Claude Code, Codex CLI, OpenCode y Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -284,18 +284,6 @@ const truncateMessage = (value: string, maxLength = 1200) => {
|
|
|
284
284
|
return `${value.slice(0, maxLength)}\n...`;
|
|
285
285
|
};
|
|
286
286
|
|
|
287
|
-
const parseWorktreePath = (command: string) => {
|
|
288
|
-
const match = command.match(
|
|
289
|
-
/git\s+worktree\s+add\s+(?:-[^\s]+\s+)*(?:-b\s+[^\s]+\s+)?(?:"([^"]+)"|'([^']+)'|(\S+))/i,
|
|
290
|
-
);
|
|
291
|
-
return match?.[1] || match?.[2] || match?.[3] || null;
|
|
292
|
-
};
|
|
293
|
-
|
|
294
|
-
const getWorktreeName = (worktreePath: string) => {
|
|
295
|
-
const segments = toSlashes(worktreePath).split("/").filter(Boolean);
|
|
296
|
-
return segments[segments.length - 1] || null;
|
|
297
|
-
};
|
|
298
|
-
|
|
299
287
|
const normalizePath = (value: string) =>
|
|
300
288
|
toSlashes(value).replace(/\/+$/, "");
|
|
301
289
|
|
|
@@ -328,6 +316,132 @@ const isMissingFileError = (error: unknown) => {
|
|
|
328
316
|
);
|
|
329
317
|
};
|
|
330
318
|
|
|
319
|
+
// Pre/post validation against real Git state. The Bash command is only a
|
|
320
|
+
// cheap candidate filter; path, branch and detached state come from `git
|
|
321
|
+
// worktree list --porcelain -z` (NUL-separated fields, NUL-terminated
|
|
322
|
+
// records). Repository identity comes from `git rev-parse
|
|
323
|
+
// --path-format=absolute --git-common-dir`, which is shared by every worktree
|
|
324
|
+
// of one repository: it is the sufficient authority, so sibling linked
|
|
325
|
+
// worktrees are admitted and foreign repositories are rejected.
|
|
326
|
+
const isWorktreeAddCommand = (command: string) =>
|
|
327
|
+
/\bgit\b.*\bworktree\s+add\b/i.test(command);
|
|
328
|
+
|
|
329
|
+
interface PorcelainWorktree {
|
|
330
|
+
path: string;
|
|
331
|
+
branch?: string;
|
|
332
|
+
detached: boolean;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const NUL = String.fromCharCode(0);
|
|
336
|
+
|
|
337
|
+
const parsePorcelain = (raw: string): PorcelainWorktree[] => {
|
|
338
|
+
const text = String(raw);
|
|
339
|
+
if (!text) throw new Error("Empty porcelain inventory.");
|
|
340
|
+
if (!text.endsWith(NUL + NUL)) {
|
|
341
|
+
throw new Error("Truncated porcelain inventory: missing record terminator.");
|
|
342
|
+
}
|
|
343
|
+
const chunks = text.split(NUL + NUL);
|
|
344
|
+
chunks.pop();
|
|
345
|
+
const entries: PorcelainWorktree[] = [];
|
|
346
|
+
const seenBranches = new Set<string>();
|
|
347
|
+
for (const chunk of chunks) {
|
|
348
|
+
if (!chunk) throw new Error("Malformed porcelain inventory: empty record.");
|
|
349
|
+
const fields = chunk.split(NUL);
|
|
350
|
+
const first = fields[0];
|
|
351
|
+
if (typeof first !== "string" || !first.startsWith("worktree ")) {
|
|
352
|
+
throw new Error("Malformed porcelain inventory: record without worktree path.");
|
|
353
|
+
}
|
|
354
|
+
const worktreePath = first.slice("worktree ".length);
|
|
355
|
+
if (!worktreePath) {
|
|
356
|
+
throw new Error("Malformed porcelain inventory: empty worktree path.");
|
|
357
|
+
}
|
|
358
|
+
let head: string | undefined;
|
|
359
|
+
let branch: string | undefined;
|
|
360
|
+
let sawBranch = false;
|
|
361
|
+
let detached = false;
|
|
362
|
+
for (const field of fields.slice(1)) {
|
|
363
|
+
if (field.startsWith("worktree ")) {
|
|
364
|
+
throw new Error("Malformed porcelain inventory: nested worktree record.");
|
|
365
|
+
} else if (field.startsWith("HEAD ")) {
|
|
366
|
+
if (head !== undefined) {
|
|
367
|
+
throw new Error("Malformed porcelain inventory: duplicate HEAD.");
|
|
368
|
+
}
|
|
369
|
+
head = field;
|
|
370
|
+
} else if (field.startsWith("branch ")) {
|
|
371
|
+
if (sawBranch) {
|
|
372
|
+
throw new Error("Malformed porcelain inventory: duplicate branch.");
|
|
373
|
+
}
|
|
374
|
+
sawBranch = true;
|
|
375
|
+
const ref = field.slice("branch ".length).trim();
|
|
376
|
+
const prefix = "refs/heads/";
|
|
377
|
+
if (!ref.startsWith(prefix) || ref.length === prefix.length) {
|
|
378
|
+
throw new Error("Malformed porcelain inventory: malformed branch ref.");
|
|
379
|
+
}
|
|
380
|
+
branch = ref.slice(prefix.length);
|
|
381
|
+
} else if (field === "detached") {
|
|
382
|
+
if (detached) {
|
|
383
|
+
throw new Error("Malformed porcelain inventory: duplicate detached.");
|
|
384
|
+
}
|
|
385
|
+
detached = true;
|
|
386
|
+
}
|
|
387
|
+
// Any other legitimate optional Git field (bare, locked, prunable, ...)
|
|
388
|
+
// is accepted as-is.
|
|
389
|
+
}
|
|
390
|
+
if (head === undefined) {
|
|
391
|
+
throw new Error("Malformed porcelain inventory: record without HEAD.");
|
|
392
|
+
}
|
|
393
|
+
if (branch !== undefined && detached) {
|
|
394
|
+
throw new Error("Malformed porcelain inventory: branch with detached HEAD.");
|
|
395
|
+
}
|
|
396
|
+
if (branch !== undefined) {
|
|
397
|
+
if (seenBranches.has(branch)) {
|
|
398
|
+
throw new Error(`Malformed porcelain inventory: duplicate branch ${branch}.`);
|
|
399
|
+
}
|
|
400
|
+
seenBranches.add(branch);
|
|
401
|
+
}
|
|
402
|
+
entries.push({ path: toSlashes(worktreePath), branch, detached });
|
|
403
|
+
}
|
|
404
|
+
return entries;
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const diffNewWorktrees = (
|
|
408
|
+
pre: PorcelainWorktree[],
|
|
409
|
+
post: PorcelainWorktree[],
|
|
410
|
+
) => post.filter((postEntry) => !pre.some((preEntry) => samePath(preEntry.path, postEntry.path)));
|
|
411
|
+
|
|
412
|
+
const getCallID = (input: any, output: any) => {
|
|
413
|
+
const value =
|
|
414
|
+
input?.callID ?? input?.callId ?? output?.callID ?? output?.callId;
|
|
415
|
+
return typeof value === "string" && value ? value : undefined;
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const getToolArgs = (input: any, output: any): Record<string, unknown> => {
|
|
419
|
+
const args = input?.args ?? output?.args;
|
|
420
|
+
return isPlainObject(args) ? args : {};
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
interface PendingCapturing {
|
|
424
|
+
status: "capturing";
|
|
425
|
+
cwd: string;
|
|
426
|
+
ambiguous: boolean;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
interface PendingReadyCapture {
|
|
430
|
+
status: "ready";
|
|
431
|
+
pre: string;
|
|
432
|
+
repo: string;
|
|
433
|
+
cwd: string;
|
|
434
|
+
ambiguous: boolean;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
interface PendingFailedCapture {
|
|
438
|
+
status: "failed";
|
|
439
|
+
cwd: string;
|
|
440
|
+
error: string;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
type PendingCapture = PendingCapturing | PendingReadyCapture | PendingFailedCapture;
|
|
444
|
+
|
|
331
445
|
export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
|
|
332
446
|
let config: WorktreePluginConfig = {
|
|
333
447
|
pathContains: "worktrees/",
|
|
@@ -371,12 +485,74 @@ export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
|
|
|
371
485
|
}
|
|
372
486
|
}
|
|
373
487
|
|
|
488
|
+
const pending = new Map<string, PendingCapture>();
|
|
489
|
+
|
|
490
|
+
const readCommonDir = async (cwd: string) =>
|
|
491
|
+
toSlashes(
|
|
492
|
+
String(
|
|
493
|
+
await $`git -C ${cwd} rev-parse --path-format=absolute --git-common-dir`.quiet().text(),
|
|
494
|
+
).trim(),
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
const failCapture = async (callID: string, cwd: string, cause: unknown) => {
|
|
498
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
499
|
+
pending.set(callID, { status: "failed", cwd, error: truncateMessage(message) });
|
|
500
|
+
await logToOpenCode(client, "error", "Worktree pre-inventory failed", {
|
|
501
|
+
error: message,
|
|
502
|
+
});
|
|
503
|
+
};
|
|
504
|
+
|
|
374
505
|
return {
|
|
506
|
+
"tool.execute.before": async (input: any, output: any) => {
|
|
507
|
+
try {
|
|
508
|
+
const tool = String(input?.tool || "").toLowerCase();
|
|
509
|
+
if (tool !== "bash") return;
|
|
510
|
+
const args = getToolArgs(input, output);
|
|
511
|
+
const command = args.command;
|
|
512
|
+
if (typeof command !== "string" || !isWorktreeAddCommand(command)) return;
|
|
513
|
+
const callID = getCallID(input, output);
|
|
514
|
+
if (!callID) return;
|
|
515
|
+
const commandCwd = getCommandCwd(args, directory);
|
|
516
|
+
const provisional: PendingCapturing = { status: "capturing", cwd: commandCwd, ambiguous: false };
|
|
517
|
+
for (const [id, cap] of pending) {
|
|
518
|
+
if (id !== callID) {
|
|
519
|
+
if (cap.status === "capturing" || cap.status === "ready") cap.ambiguous = true;
|
|
520
|
+
provisional.ambiguous = true;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
pending.set(callID, provisional);
|
|
524
|
+
let preRaw: string;
|
|
525
|
+
try {
|
|
526
|
+
preRaw = String(
|
|
527
|
+
await $`git -C ${commandCwd} worktree list --porcelain -z`.quiet().text(),
|
|
528
|
+
);
|
|
529
|
+
} catch (error) {
|
|
530
|
+
await failCapture(callID, commandCwd, error);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
let repo: string;
|
|
534
|
+
try {
|
|
535
|
+
repo = await readCommonDir(commandCwd);
|
|
536
|
+
if (!isAbsolutePath(repo)) {
|
|
537
|
+
throw new Error(`empty repository identity (common-dir) for ${commandCwd}`);
|
|
538
|
+
}
|
|
539
|
+
} catch (error) {
|
|
540
|
+
await failCapture(callID, commandCwd, error);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
pending.set(callID, { status: "ready", pre: preRaw, repo, cwd: commandCwd, ambiguous: provisional.ambiguous });
|
|
544
|
+
} catch (error) {
|
|
545
|
+
await logToOpenCode(client, "error", "Worktree plugin execution failed", {
|
|
546
|
+
error: error instanceof Error ? error.message : String(error),
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
},
|
|
375
550
|
"tool.execute.after": async (input: any, output: any) => {
|
|
376
551
|
try {
|
|
377
|
-
const tool = (input.tool || "").toLowerCase();
|
|
378
|
-
const args = input
|
|
379
|
-
const command =
|
|
552
|
+
const tool = ((input.tool || "") as string).toLowerCase();
|
|
553
|
+
const args = getToolArgs(input, output);
|
|
554
|
+
const command =
|
|
555
|
+
typeof args.command === "string" ? (args.command as string) : "";
|
|
380
556
|
|
|
381
557
|
const payload = {
|
|
382
558
|
event: "tool.execute.after",
|
|
@@ -413,107 +589,254 @@ export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
|
|
|
413
589
|
|
|
414
590
|
if (tool !== "bash") return;
|
|
415
591
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
592
|
+
if (!isWorktreeAddCommand(command)) {
|
|
593
|
+
const stale = getCallID(input, output);
|
|
594
|
+
if (stale) pending.delete(stale);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
420
597
|
|
|
421
|
-
|
|
598
|
+
const callID = getCallID(input, output);
|
|
599
|
+
const capture = callID ? pending.get(callID) : undefined;
|
|
600
|
+
if (!callID || !capture) {
|
|
601
|
+
appendToolOutput(output, [
|
|
602
|
+
"Worktree creation is ambiguous: missing pre-execution inventory. Skipping setup.",
|
|
603
|
+
"Run `git worktree list --porcelain` to inspect the current inventory.",
|
|
604
|
+
]);
|
|
422
605
|
return;
|
|
423
606
|
}
|
|
607
|
+
try {
|
|
608
|
+
if (capture.status === "failed") {
|
|
609
|
+
appendToolOutput(output, [
|
|
610
|
+
`Worktree pre-inventory capture failed for ${capture.cwd}; skipping worktree setup.`,
|
|
611
|
+
"Run `git worktree list --porcelain -z` from the project root and retry.",
|
|
612
|
+
"Run `git rev-parse --show-toplevel` from the project root and retry.",
|
|
613
|
+
...(capture.error ? [`Details: ${capture.error}`] : []),
|
|
614
|
+
]);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (capture.status === "capturing") {
|
|
619
|
+
appendToolOutput(output, [
|
|
620
|
+
"Worktree creation is ambiguous: overlapping candidate commands in the same repository cannot be attributed to a single call. Skipping setup.",
|
|
621
|
+
"Run `git worktree list --porcelain` to inspect the current inventory.",
|
|
622
|
+
]);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
let preList: PorcelainWorktree[];
|
|
627
|
+
try {
|
|
628
|
+
preList = parsePorcelain(capture.pre);
|
|
629
|
+
} catch (error) {
|
|
630
|
+
appendToolOutput(output, [
|
|
631
|
+
"Worktree inventory is unreadable; skipping worktree setup.",
|
|
632
|
+
"Run `git worktree list --porcelain` from the project root and retry.",
|
|
633
|
+
]);
|
|
634
|
+
await logToOpenCode(client, "error", "Worktree inventory parse failed", {
|
|
635
|
+
error: error instanceof Error ? error.message : String(error),
|
|
636
|
+
});
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
424
639
|
|
|
425
|
-
|
|
426
|
-
|
|
640
|
+
const exit = output?.metadata?.exit;
|
|
641
|
+
if (typeof exit !== "number" || exit !== 0) {
|
|
642
|
+
const ambiguous =
|
|
643
|
+
exit === undefined || exit === null
|
|
644
|
+
? "Worktree command result is ambiguous (missing exit code); skipping worktree setup."
|
|
645
|
+
: `Worktree command did not succeed (exit ${String(exit)}); skipping worktree setup.`;
|
|
646
|
+
appendToolOutput(output, [ambiguous]);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
427
649
|
|
|
428
|
-
|
|
429
|
-
|
|
650
|
+
if (capture.ambiguous) {
|
|
651
|
+
appendToolOutput(output, [
|
|
652
|
+
"Worktree creation is ambiguous: overlapping candidate commands in the same repository cannot be attributed to a single call. Skipping setup.",
|
|
653
|
+
"Run `git worktree list --porcelain` to inspect the current inventory.",
|
|
654
|
+
]);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
430
657
|
|
|
431
|
-
|
|
658
|
+
const commandCwd = getCommandCwd(args, directory);
|
|
659
|
+
let postRaw: string;
|
|
660
|
+
let gitRoot: string;
|
|
661
|
+
let commonCwd: string;
|
|
662
|
+
let commonDir: string;
|
|
663
|
+
try {
|
|
664
|
+
postRaw = String(
|
|
665
|
+
await $`git -C ${commandCwd} worktree list --porcelain -z`.quiet().text(),
|
|
666
|
+
);
|
|
667
|
+
gitRoot = String(
|
|
668
|
+
await $`git -C ${commandCwd} rev-parse --show-toplevel`.quiet().text(),
|
|
669
|
+
)
|
|
670
|
+
.trim()
|
|
671
|
+
.replace(/\\/g, "/");
|
|
672
|
+
commonCwd = await readCommonDir(commandCwd);
|
|
673
|
+
commonDir = await readCommonDir(directory);
|
|
674
|
+
} catch (error) {
|
|
675
|
+
const details = truncateMessage(
|
|
676
|
+
error instanceof Error ? error.message : String(error),
|
|
677
|
+
);
|
|
678
|
+
appendToolOutput(output, [
|
|
679
|
+
"Worktree plugin could not process this worktree command.",
|
|
680
|
+
"Run `git rev-parse --show-toplevel` from the project root and retry.",
|
|
681
|
+
...(details ? [`Details: ${details}`] : []),
|
|
682
|
+
]);
|
|
683
|
+
await logToOpenCode(client, "error", "Worktree plugin execution failed", {
|
|
684
|
+
error: error instanceof Error ? error.message : String(error),
|
|
685
|
+
});
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
432
688
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
689
|
+
// A concurrent candidate may have started while the post reads above
|
|
690
|
+
// were in flight and flagged this capture: re-check after every await
|
|
691
|
+
// and right before attributing effects.
|
|
692
|
+
if (capture.ambiguous) {
|
|
693
|
+
appendToolOutput(output, [
|
|
694
|
+
"Worktree creation is ambiguous: overlapping candidate commands in the same repository cannot be attributed to a single call. Skipping setup.",
|
|
695
|
+
"Run `git worktree list --porcelain` to inspect the current inventory.",
|
|
696
|
+
]);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
442
699
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
`Use the project-local path instead: ${expectedWorktreePath}`,
|
|
451
|
-
"Canonical rule: <project-root>/worktrees/<canonical-name> or <project-root>/worktrees/<canonical-name>-prNN.",
|
|
452
|
-
]);
|
|
453
|
-
}
|
|
700
|
+
if (!samePath(commonCwd, capture.repo)) {
|
|
701
|
+
appendToolOutput(output, [
|
|
702
|
+
"Worktree creation is ambiguous: repository identity changed between pre- and post-execution inventory. Skipping setup.",
|
|
703
|
+
"Run `git worktree list --porcelain` to inspect the current inventory.",
|
|
704
|
+
]);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
454
707
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
708
|
+
const toplevel = toSlashes(gitRoot).replace(/\/+$/, "");
|
|
709
|
+
if (!samePath(commonCwd, commonDir)) {
|
|
710
|
+
appendToolOutput(output, [
|
|
711
|
+
`Worktree command targets a foreign repository: ${toplevel}. Skipping setup.`,
|
|
712
|
+
`The plugin directory belongs to a different repository (${normalizePath(directory)}). Run the command inside the project repository.`,
|
|
713
|
+
]);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
459
716
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
717
|
+
let postList: PorcelainWorktree[];
|
|
718
|
+
try {
|
|
719
|
+
postList = parsePorcelain(postRaw);
|
|
720
|
+
} catch (error) {
|
|
721
|
+
appendToolOutput(output, [
|
|
722
|
+
"Worktree inventory is unreadable; skipping worktree setup.",
|
|
723
|
+
"Run `git worktree list --porcelain` from the project root and retry.",
|
|
724
|
+
]);
|
|
725
|
+
await logToOpenCode(client, "error", "Worktree inventory parse failed", {
|
|
726
|
+
error: error instanceof Error ? error.message : String(error),
|
|
727
|
+
});
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
463
730
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
731
|
+
const added = diffNewWorktrees(preList, postList);
|
|
732
|
+
if (added.length !== 1) {
|
|
733
|
+
appendToolOutput(output, [
|
|
734
|
+
`Worktree creation is ambiguous: expected 1 new worktree, found ${added.length}. Skipping setup.`,
|
|
735
|
+
"Run `git worktree list --porcelain` to inspect the current inventory.",
|
|
736
|
+
]);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
467
739
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
payload: {
|
|
472
|
-
...payload,
|
|
473
|
-
worktreePath: absoluteWorktreePath,
|
|
474
|
-
worktreeName,
|
|
475
|
-
branchName,
|
|
476
|
-
},
|
|
477
|
-
commandArgs: ["-WorktreePath", absoluteWorktreePath],
|
|
478
|
-
activeWorktreePath: absoluteWorktreePath,
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
if (setupResult.exitCode !== 0) {
|
|
482
|
-
const setupErrorMessage = truncateMessage(
|
|
483
|
-
[setupResult.stderr.trim(), setupResult.stdout.trim()]
|
|
484
|
-
.filter(Boolean)
|
|
485
|
-
.join("\n\n"),
|
|
486
|
-
);
|
|
740
|
+
const created = added[0] as PorcelainWorktree;
|
|
741
|
+
const branchName = created.detached ? undefined : created.branch;
|
|
742
|
+
if (!branchName) {
|
|
487
743
|
appendToolOutput(output, [
|
|
488
|
-
|
|
489
|
-
|
|
744
|
+
created.detached
|
|
745
|
+
? "Worktree is detached; skipping setup. Use a branch worktree under <project-root>/worktrees/<branch>."
|
|
746
|
+
: "Worktree branch could not be determined from Git; skipping setup.",
|
|
747
|
+
"Run `git worktree list --porcelain` to inspect the current inventory.",
|
|
490
748
|
]);
|
|
491
|
-
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const absoluteWorktreePath = toSlashes(created.path);
|
|
753
|
+
const projectRoot = toplevel;
|
|
754
|
+
const expectedWorktreePath = resolvePath(
|
|
755
|
+
projectRoot,
|
|
756
|
+
`worktrees/${branchName}`,
|
|
757
|
+
);
|
|
758
|
+
const pathContains = toSlashes(
|
|
759
|
+
config.pathContains || "worktrees/",
|
|
760
|
+
).toLowerCase();
|
|
761
|
+
|
|
762
|
+
const isCanonicalPath = samePath(
|
|
763
|
+
absoluteWorktreePath,
|
|
764
|
+
expectedWorktreePath,
|
|
765
|
+
);
|
|
766
|
+
if (!isCanonicalPath) {
|
|
492
767
|
appendToolOutput(output, [
|
|
493
|
-
`Worktree
|
|
768
|
+
`Worktree path is not canonical: ${absoluteWorktreePath}`,
|
|
769
|
+
`Use the project-local path instead: ${expectedWorktreePath}`,
|
|
770
|
+
"Canonical rule: <project-root>/worktrees/<canonical-name> or <project-root>/worktrees/<canonical-name>-prNN.",
|
|
494
771
|
]);
|
|
495
772
|
}
|
|
496
|
-
}
|
|
497
773
|
|
|
498
|
-
|
|
499
|
-
|
|
774
|
+
if (configError) {
|
|
775
|
+
appendToolOutput(output, [configError]);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (!isCanonicalPath) {
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if (!normalizePath(absoluteWorktreePath).toLowerCase().includes(pathContains)) {
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const setupScript = resolveProjectPath(directory, config.setupScript);
|
|
788
|
+
if (setupScript) {
|
|
789
|
+
const setupResult = await runScript(client, directory, setupScript, {
|
|
790
|
+
payload: {
|
|
791
|
+
...payload,
|
|
792
|
+
worktreePath: absoluteWorktreePath,
|
|
793
|
+
worktreeName: branchName,
|
|
794
|
+
branchName,
|
|
795
|
+
},
|
|
796
|
+
commandArgs: ["-WorktreePath", absoluteWorktreePath],
|
|
797
|
+
activeWorktreePath: absoluteWorktreePath,
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
if (setupResult.exitCode !== 0) {
|
|
801
|
+
const setupErrorMessage = truncateMessage(
|
|
802
|
+
[setupResult.stderr.trim(), setupResult.stdout.trim()]
|
|
803
|
+
.filter(Boolean)
|
|
804
|
+
.join("\n\n"),
|
|
805
|
+
);
|
|
806
|
+
appendToolOutput(output, [
|
|
807
|
+
`Worktree setup failed for ${branchName}.`,
|
|
808
|
+
setupErrorMessage || "No additional details from setup script.",
|
|
809
|
+
]);
|
|
810
|
+
} else {
|
|
811
|
+
appendToolOutput(output, [
|
|
812
|
+
`Worktree setup complete: ${branchName}`,
|
|
813
|
+
]);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const reminderLines = (config.reminderLines || []).map((line) =>
|
|
500
818
|
replaceToken(
|
|
501
|
-
replaceToken(
|
|
502
|
-
|
|
503
|
-
|
|
819
|
+
replaceToken(
|
|
820
|
+
replaceToken(line, "{worktreeName}", branchName),
|
|
821
|
+
"{worktreePath}",
|
|
822
|
+
absoluteWorktreePath,
|
|
823
|
+
),
|
|
824
|
+
"{branchName}",
|
|
825
|
+
branchName,
|
|
504
826
|
),
|
|
505
|
-
|
|
506
|
-
branchName,
|
|
507
|
-
),
|
|
508
|
-
);
|
|
827
|
+
);
|
|
509
828
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
829
|
+
if (reminderLines.length > 0) {
|
|
830
|
+
const banner = [
|
|
831
|
+
"--------------------------------------------------",
|
|
832
|
+
...reminderLines,
|
|
833
|
+
"--------------------------------------------------",
|
|
834
|
+
].join("\n");
|
|
835
|
+
appendToolOutput(output, [banner]);
|
|
836
|
+
}
|
|
837
|
+
return;
|
|
838
|
+
} finally {
|
|
839
|
+
if (pending.get(callID) === capture) pending.delete(callID);
|
|
517
840
|
}
|
|
518
841
|
} catch (error) {
|
|
519
842
|
const details = truncateMessage(
|