@outcomeeng/spx 0.5.5 → 0.5.6

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/dist/cli.js CHANGED
@@ -1066,11 +1066,13 @@ var STATE_STORE_ERROR = {
1066
1066
  RECORD_WRITE_FAILED: "state-store record write failed",
1067
1067
  RECORD_READ_FAILED: "state-store record read failed"
1068
1068
  };
1069
+ var STATE_STORE_RUN_TOKEN = {
1070
+ ID_BYTES: 6,
1071
+ TIMESTAMP_MILLISECOND_DIGITS: 3
1072
+ };
1069
1073
  var HEX_ENCODING = "hex";
1070
- var RUN_ID_BYTES = 6;
1071
1074
  var RUN_FILE_CREATE_ATTEMPTS = 10;
1072
1075
  var RUN_TIMESTAMP_SEPARATOR = "_";
1073
- var RUN_TIMESTAMP_MILLISECOND_DIGITS = 3;
1074
1076
  var SLUG_SEPARATOR = "-";
1075
1077
  var EMPTY_STRING = "";
1076
1078
  var RUN_TOKEN_PATTERN = /^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-\d{3}-[a-f0-9]{12}$/;
@@ -1144,11 +1146,19 @@ function formatRunTimestamp(date) {
1144
1146
  const hours = String(date.getUTCHours()).padStart(2, "0");
1145
1147
  const minutes = String(date.getUTCMinutes()).padStart(2, "0");
1146
1148
  const seconds = String(date.getUTCSeconds()).padStart(2, "0");
1147
- const milliseconds = String(date.getUTCMilliseconds()).padStart(RUN_TIMESTAMP_MILLISECOND_DIGITS, "0");
1149
+ const milliseconds = String(date.getUTCMilliseconds()).padStart(
1150
+ STATE_STORE_RUN_TOKEN.TIMESTAMP_MILLISECOND_DIGITS,
1151
+ "0"
1152
+ );
1148
1153
  return `${year}-${month}-${day}${RUN_TIMESTAMP_SEPARATOR}${hours}-${minutes}-${seconds}-${milliseconds}`;
1149
1154
  }
1150
1155
  function generateRunId(randomBytes = nodeRandomBytes) {
1151
- return randomBytes(RUN_ID_BYTES).toString(HEX_ENCODING);
1156
+ return randomBytes(STATE_STORE_RUN_TOKEN.ID_BYTES).toString(HEX_ENCODING);
1157
+ }
1158
+ function createStateStoreRunToken(options) {
1159
+ const startedAt = formatRunTimestamp(options.date);
1160
+ const runId = generateRunId(options.randomBytes);
1161
+ return { runToken: `${startedAt}${SLUG_SEPARATOR}${runId}`, runId, startedAt };
1152
1162
  }
1153
1163
  function runFileName(runToken) {
1154
1164
  return `${STATE_STORE_PATH.RUN_FILE_PREFIX}${runToken}${STATE_STORE_PATH.JSONL_EXTENSION}`;
@@ -1165,7 +1175,6 @@ async function createJsonlRunFile(scopeDir, domainName, options = {}) {
1165
1175
  if (!domainRunsDir.ok) return domainRunsDir;
1166
1176
  const maxAttempts = options.maxAttempts ?? RUN_FILE_CREATE_ATTEMPTS;
1167
1177
  const startedDate = (options.now ?? (() => /* @__PURE__ */ new Date()))();
1168
- const startedAt = formatRunTimestamp(startedDate);
1169
1178
  const randomBytes = options.randomBytes ?? nodeRandomBytes;
1170
1179
  try {
1171
1180
  await fs7.mkdir(domainRunsDir.value, { recursive: true });
@@ -1176,15 +1185,14 @@ async function createJsonlRunFile(scopeDir, domainName, options = {}) {
1176
1185
  };
1177
1186
  }
1178
1187
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
1179
- const runId = generateRunId(randomBytes);
1180
- const runToken = `${startedAt}${SLUG_SEPARATOR}${runId}`;
1181
- const name = runFileName(runToken);
1188
+ const token = createStateStoreRunToken({ date: startedDate, randomBytes });
1189
+ const name = runFileName(token.runToken);
1182
1190
  const path6 = join2(domainRunsDir.value, name);
1183
1191
  try {
1184
1192
  await fs7.writeFile(path6, EMPTY_STRING, { flag: EXCLUSIVE_CREATE_FLAG });
1185
1193
  return {
1186
1194
  ok: true,
1187
- value: { runsDir: domainRunsDir.value, runFilePath: path6, runFileName: name, runToken, runId, startedAt }
1195
+ value: { runsDir: domainRunsDir.value, runFilePath: path6, runFileName: name, ...token }
1188
1196
  };
1189
1197
  } catch (error) {
1190
1198
  if (hasErrorCode(error, ERROR_CODE_FILE_EXISTS)) continue;
@@ -2382,11 +2390,6 @@ function knipAdapter(scope, config) {
2382
2390
  return scope.excluded.flatMap((entry) => [config.ignoreFlag, entry.path]);
2383
2391
  }
2384
2392
 
2385
- // src/lib/file-inclusion/adapters/madge.ts
2386
- function madgeAdapter(scope, config) {
2387
- return scope.excluded.flatMap((entry) => [config.ignoreFlag, entry.path]);
2388
- }
2389
-
2390
2393
  // src/lib/file-inclusion/adapters/markdownlint.ts
2391
2394
  function markdownlintAdapter(scope, config) {
2392
2395
  return scope.excluded.flatMap((entry) => [config.ignoreFlag, entry.path]);
@@ -2411,7 +2414,6 @@ function vitestAdapter(scope, config) {
2411
2414
  var TOOL_DEFAULT_FLAGS = {
2412
2415
  eslint: "--ignore-pattern",
2413
2416
  tsc: "--exclude",
2414
- madge: "--exclude",
2415
2417
  knip: "--exclude",
2416
2418
  markdownlint: "--ignore",
2417
2419
  pytest: "--ignore",
@@ -2420,7 +2422,6 @@ var TOOL_DEFAULT_FLAGS = {
2420
2422
  var ADAPTER_MAP = {
2421
2423
  eslint: eslintAdapter,
2422
2424
  tsc: tscAdapter,
2423
- madge: madgeAdapter,
2424
2425
  knip: knipAdapter,
2425
2426
  markdownlint: markdownlintAdapter,
2426
2427
  pytest: pytestAdapter,
@@ -3499,169 +3500,800 @@ var configDomain = {
3499
3500
  }
3500
3501
  };
3501
3502
 
3502
- // src/commands/session/archive.ts
3503
- import { mkdir, rename, stat } from "fs/promises";
3504
- import { dirname as dirname3, join as join7 } from "path";
3505
-
3506
- // src/domains/session/archive.ts
3507
- var SESSION_FILE_EXTENSION = ".md";
3508
- var ARCHIVABLE_DIR_KEYS = {
3509
- TODO: "todoDir",
3510
- DOING: "doingDir"
3511
- };
3512
- var ARCHIVABLE_DIR_KEY = {
3513
- todo: ARCHIVABLE_DIR_KEYS.TODO,
3514
- doing: ARCHIVABLE_DIR_KEYS.DOING
3515
- };
3516
- function buildArchivePaths(sessionId, currentStatus, config) {
3517
- const filename = `${sessionId}${SESSION_FILE_EXTENSION}`;
3518
- const dirKey = ARCHIVABLE_DIR_KEY[currentStatus];
3519
- const sourceDir = config[dirKey];
3520
- return {
3521
- source: `${sourceDir}/${filename}`,
3522
- target: `${config.archiveDir}/${filename}`
3523
- };
3503
+ // src/lib/sanitize-cli-argument.ts
3504
+ var MAX_CLI_ARGUMENT_DISPLAY_LENGTH = 120;
3505
+ var ELLIPSIS_TOKEN = "...";
3506
+ var SENTINEL_UNDEFINED = "<undefined>";
3507
+ var SENTINEL_NULL = "<null>";
3508
+ var SENTINEL_EMPTY = "<empty>";
3509
+ var CONTROL_CHAR_UPPER_BOUND = 31;
3510
+ var DEL_CHAR_CODE = 127;
3511
+ var HEX_RADIX = 16;
3512
+ var HEX_PAD = 2;
3513
+ function formatHexEscape(code) {
3514
+ return `\\x${code.toString(HEX_RADIX).padStart(HEX_PAD, "0")}`;
3524
3515
  }
3525
- var ARCHIVE_SEARCH_ORDER = ["todo", "doing"];
3526
- function findSessionForArchive(existingPaths) {
3527
- if (existingPaths.archive !== null) {
3528
- return null;
3529
- }
3530
- for (const status of ARCHIVE_SEARCH_ORDER) {
3531
- if (existingPaths[status] !== null) {
3532
- return { status, path: existingPaths[status] };
3533
- }
3534
- }
3535
- return null;
3516
+ function nonStringSentinel(type) {
3517
+ return `<non-string:${type}>`;
3536
3518
  }
3537
-
3538
- // src/domains/session/batch.ts
3539
- var BatchError = class extends Error {
3540
- results;
3541
- constructor(results) {
3542
- const failures = results.filter((r) => !r.ok);
3543
- const successes = results.filter((r) => r.ok);
3544
- super(
3545
- `${failures.length} of ${results.length} operations failed. ${successes.length} succeeded.`
3546
- );
3547
- this.name = "BatchError";
3548
- this.results = results;
3549
- }
3550
- };
3551
- async function processBatch(ids, handler) {
3552
- const results = [];
3553
- for (const id of ids) {
3554
- try {
3555
- const output2 = await handler(id);
3556
- results.push({ id, ok: true, message: output2 });
3557
- } catch (error) {
3558
- const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
3559
- results.push({ id, ok: false, message });
3519
+ function sanitizeCliArgument(input) {
3520
+ if (input === void 0) return SENTINEL_UNDEFINED;
3521
+ if (input === null) return SENTINEL_NULL;
3522
+ if (typeof input !== "string") return nonStringSentinel(typeof input);
3523
+ if (input.length === 0) return SENTINEL_EMPTY;
3524
+ const escaped = escapeControlCharacters(input);
3525
+ return truncate(escaped);
3526
+ }
3527
+ function escapeControlCharacters(value) {
3528
+ let out = "";
3529
+ for (const char of value) {
3530
+ const code = char.codePointAt(0);
3531
+ if (code === void 0) continue;
3532
+ if (code <= CONTROL_CHAR_UPPER_BOUND || code === DEL_CHAR_CODE) {
3533
+ out += formatHexEscape(code);
3534
+ } else {
3535
+ out += char;
3560
3536
  }
3561
3537
  }
3562
- const output = results.map((r) => r.ok ? r.message : `Error (${r.id}): ${r.message}`).join("\n\n");
3563
- const hasFailures = results.some((r) => !r.ok);
3564
- if (hasFailures) {
3565
- const err = new BatchError(results);
3566
- err.message = `${err.message}
3567
-
3568
- ${output}`;
3569
- throw err;
3570
- }
3571
- return output;
3538
+ return out;
3539
+ }
3540
+ function truncate(value) {
3541
+ if (value.length <= MAX_CLI_ARGUMENT_DISPLAY_LENGTH) return value;
3542
+ return value.slice(0, MAX_CLI_ARGUMENT_DISPLAY_LENGTH - ELLIPSIS_TOKEN.length) + ELLIPSIS_TOKEN;
3572
3543
  }
3573
3544
 
3574
- // src/domains/session/handoff-base-checklist.ts
3575
- var SESSION_HANDOFF_BASE_ERROR_NAME = "SessionHandoffBaseError";
3576
- var HANDOFF_BASE_MARK = {
3577
- MET: "\u2713",
3578
- UNMET: "\u2717"
3579
- };
3580
- var HANDOFF_BASE_PREREQUISITE_LABEL = {
3581
- CLEAN_WORKING_TREE: "working tree is clean",
3582
- DETACHED_AT_DEFAULT_TIP: "HEAD is detached at the default-branch tip"
3545
+ // src/interfaces/hooks/session-start.ts
3546
+ import { appendFile as nodeAppendFile2 } from "fs/promises";
3547
+
3548
+ // src/domains/worktree/occupancy-store.ts
3549
+ import { join as join6 } from "path";
3550
+ var OCCUPANCY_STATUS = {
3551
+ UNCLAIMED: "unclaimed",
3552
+ OCCUPIED: "occupied",
3553
+ STALE: "stale"
3583
3554
  };
3584
- var HANDOFF_BASE_REMEDY = {
3585
- /** Unclean working tree: commit, or hand off from the main checkout. */
3586
- COMMIT_OR_MAIN_CHECKOUT: "commit the changes, or run handoff from the main checkout",
3587
- /** Off the default-branch tip: detach to it, or hand off from the main checkout. */
3588
- DETACH_TO_TIP_OR_MAIN_CHECKOUT: "detach HEAD to the default-branch tip, or run handoff from the main checkout",
3589
- /** Default branch unresolved: only the main checkout can anchor the base. */
3590
- MAIN_CHECKOUT_ONLY: "run handoff from the main checkout"
3555
+ var OCCUPANCY_CLAIM = {
3556
+ FILE_EXTENSION: ".claim",
3557
+ TEMP_EXTENSION: ".tmp",
3558
+ UNREADABLE_STARTED_AT_PREFIX: "unreadable:"
3591
3559
  };
3592
- var HANDOFF_BASE_FACT_LABEL = {
3593
- DEFAULT_BRANCH: "default branch",
3594
- DEFAULT_TIP: "origin tip",
3595
- HEAD: "HEAD",
3596
- CURRENT_WORKTREE: "current worktree",
3597
- MAIN_CHECKOUT: "main checkout"
3560
+ var OCCUPANCY_ERROR = {
3561
+ INVALID_NAME: "worktree occupancy claim name must be a safe path segment",
3562
+ INVALID_WRITE_TOKEN: "worktree occupancy claim write token must be a safe path segment",
3563
+ CLAIM_WRITE_FAILED: "worktree occupancy claim write failed",
3564
+ CLAIM_READ_FAILED: "worktree occupancy claim read failed",
3565
+ CLAIM_REMOVE_FAILED: "worktree occupancy claim remove failed",
3566
+ CLAIM_MALFORMED: "worktree occupancy claim record is malformed"
3598
3567
  };
3599
- var HANDOFF_BASE_UNRESOLVED = "unresolved";
3600
- var CHECKLIST_INDENT = " ";
3601
- var REMEDY_SEPARATOR = " \u2014 ";
3602
- var CHECKLIST_HEADER = `${SESSION_HANDOFF_BASE_ERROR_NAME}: cannot create a handoff session from this worktree \u2014 it is not the main checkout.`;
3603
- function renderFactLine(label, value) {
3604
- return `${CHECKLIST_INDENT}${label}: ${value ?? HANDOFF_BASE_UNRESOLVED}`;
3568
+ var ERROR_DETAIL_SEPARATOR2 = ": ";
3569
+ function claimFileName(name) {
3570
+ return `${name}${OCCUPANCY_CLAIM.FILE_EXTENSION}`;
3605
3571
  }
3606
- function renderPrerequisiteLine(prerequisite) {
3607
- const mark = prerequisite.met ? HANDOFF_BASE_MARK.MET : HANDOFF_BASE_MARK.UNMET;
3608
- const base = `${CHECKLIST_INDENT}${mark} ${prerequisite.label}`;
3609
- return prerequisite.met ? base : `${base}${REMEDY_SEPARATOR}${prerequisite.remedy}`;
3572
+ function claimFilePath(worktreesDir, name) {
3573
+ const validated = validateScopeToken(name);
3574
+ if (!validated.ok) return { ok: false, error: OCCUPANCY_ERROR.INVALID_NAME };
3575
+ return { ok: true, value: join6(worktreesDir, claimFileName(validated.value)) };
3610
3576
  }
3611
- function renderHandoffBaseChecklist(checklist) {
3612
- return [
3613
- CHECKLIST_HEADER,
3614
- renderFactLine(HANDOFF_BASE_FACT_LABEL.DEFAULT_BRANCH, checklist.defaultBranch),
3615
- renderFactLine(HANDOFF_BASE_FACT_LABEL.DEFAULT_TIP, checklist.defaultTipSha),
3616
- renderFactLine(HANDOFF_BASE_FACT_LABEL.HEAD, checklist.headSha),
3617
- renderFactLine(HANDOFF_BASE_FACT_LABEL.CURRENT_WORKTREE, checklist.currentWorktreePath),
3618
- renderFactLine(HANDOFF_BASE_FACT_LABEL.MAIN_CHECKOUT, checklist.mainCheckoutPath),
3619
- ...checklist.prerequisites.map(renderPrerequisiteLine)
3620
- ].join("\n");
3577
+ function claimTempFilePath(claimPath, writeToken) {
3578
+ const validated = validateScopeToken(writeToken);
3579
+ if (!validated.ok) return { ok: false, error: OCCUPANCY_ERROR.INVALID_WRITE_TOKEN };
3580
+ return { ok: true, value: `${claimPath}.${validated.value}${OCCUPANCY_CLAIM.TEMP_EXTENSION}` };
3621
3581
  }
3622
-
3623
- // src/domains/session/errors.ts
3624
- var SessionError = class extends Error {
3625
- constructor(message) {
3626
- super(message);
3627
- this.name = "SessionError";
3628
- }
3629
- };
3630
- var SessionNotFoundError = class extends SessionError {
3631
- /** The session ID that was not found */
3632
- sessionId;
3633
- constructor(sessionId) {
3634
- super(`Session not found: ${sessionId}. Check the session ID and try again.`);
3635
- this.name = "SessionNotFoundError";
3636
- this.sessionId = sessionId;
3582
+ function classifyOccupancy(claim, probe) {
3583
+ if (claim === void 0) return OCCUPANCY_STATUS.UNCLAIMED;
3584
+ if (claim.host !== probe.currentHost()) return OCCUPANCY_STATUS.STALE;
3585
+ if (!probe.isAlive(claim.pid)) return OCCUPANCY_STATUS.STALE;
3586
+ const liveStartTime = probe.startTimeOf(claim.pid);
3587
+ if (claim.startedAt === unreadableStartedAt(claim.pid)) return OCCUPANCY_STATUS.OCCUPIED;
3588
+ if (liveStartTime !== void 0 && liveStartTime !== claim.startedAt) return OCCUPANCY_STATUS.STALE;
3589
+ return OCCUPANCY_STATUS.OCCUPIED;
3590
+ }
3591
+ function unreadableStartedAt(pid) {
3592
+ return `${OCCUPANCY_CLAIM.UNREADABLE_STARTED_AT_PREFIX}${pid}`;
3593
+ }
3594
+ async function writeClaim(worktreesDir, name, record, options) {
3595
+ const pathResult = claimFilePath(worktreesDir, name);
3596
+ if (!pathResult.ok) return pathResult;
3597
+ const claimPath = pathResult.value;
3598
+ const tempPath = claimTempFilePath(claimPath, options.writeToken);
3599
+ if (!tempPath.ok) return tempPath;
3600
+ try {
3601
+ await options.fs.mkdir(worktreesDir, { recursive: true });
3602
+ await options.fs.writeFile(tempPath.value, serializeClaim(record));
3603
+ await options.fs.rename(tempPath.value, claimPath);
3604
+ return { ok: true, value: claimPath };
3605
+ } catch (error) {
3606
+ return { ok: false, error: formatOccupancyError(OCCUPANCY_ERROR.CLAIM_WRITE_FAILED, toErrorMessage2(error)) };
3637
3607
  }
3638
- };
3639
- var SessionNotAvailableError = class extends SessionError {
3640
- /** The session ID that is not available */
3641
- sessionId;
3642
- constructor(sessionId) {
3643
- super(`Session not available: ${sessionId}. It may have been claimed by another agent.`);
3644
- this.name = "SessionNotAvailableError";
3645
- this.sessionId = sessionId;
3608
+ }
3609
+ async function readClaim(worktreesDir, name, options) {
3610
+ const pathResult = claimFilePath(worktreesDir, name);
3611
+ if (!pathResult.ok) return pathResult;
3612
+ let content;
3613
+ try {
3614
+ content = await options.fs.readFile(pathResult.value, "utf8");
3615
+ } catch (error) {
3616
+ if (hasErrorCode(error, ERROR_CODE_NOT_FOUND)) return { ok: true, value: void 0 };
3617
+ return { ok: false, error: formatOccupancyError(OCCUPANCY_ERROR.CLAIM_READ_FAILED, toErrorMessage2(error)) };
3646
3618
  }
3647
- };
3648
- var SessionInvalidContentError = class extends SessionError {
3649
- constructor(reason) {
3650
- super(`Invalid session content: ${reason}`);
3651
- this.name = "SessionInvalidContentError";
3619
+ return parseClaim(content);
3620
+ }
3621
+ async function removeClaim(worktreesDir, name, options) {
3622
+ const pathResult = claimFilePath(worktreesDir, name);
3623
+ if (!pathResult.ok) return pathResult;
3624
+ try {
3625
+ await options.fs.rm(pathResult.value, { force: true });
3626
+ return { ok: true, value: void 0 };
3627
+ } catch (error) {
3628
+ return { ok: false, error: formatOccupancyError(OCCUPANCY_ERROR.CLAIM_REMOVE_FAILED, toErrorMessage2(error)) };
3652
3629
  }
3653
- };
3654
- var SessionInvalidGoalError = class extends SessionError {
3655
- constructor() {
3656
- super("Invalid session goal: goal must be a non-empty string.");
3657
- this.name = "SessionInvalidGoalError";
3630
+ }
3631
+ async function readOccupancy(worktreesDir, name, probe, options) {
3632
+ const claimResult = await readClaim(worktreesDir, name, options);
3633
+ if (!claimResult.ok) return claimResult;
3634
+ return { ok: true, value: classifyOccupancy(claimResult.value, probe) };
3635
+ }
3636
+ function serializeClaim(record) {
3637
+ return JSON.stringify({
3638
+ sessionId: record.sessionId,
3639
+ host: record.host,
3640
+ pid: record.pid,
3641
+ startedAt: record.startedAt
3642
+ });
3643
+ }
3644
+ function parseClaim(content) {
3645
+ let parsed;
3646
+ try {
3647
+ parsed = JSON.parse(content);
3648
+ } catch {
3649
+ return { ok: false, error: OCCUPANCY_ERROR.CLAIM_MALFORMED };
3658
3650
  }
3659
- };
3660
- var SessionInvalidFieldError = class extends SessionError {
3661
- /** The unknown field token the caller supplied. */
3662
- field;
3663
- /** The full set of valid field names. */
3664
- validFields;
3651
+ if (!isWorktreeClaimRecord(parsed)) return { ok: false, error: OCCUPANCY_ERROR.CLAIM_MALFORMED };
3652
+ return { ok: true, value: parsed };
3653
+ }
3654
+ function isWorktreeClaimRecord(value) {
3655
+ if (typeof value !== "object" || value === null) return false;
3656
+ const candidate = value;
3657
+ return typeof candidate.sessionId === "string" && typeof candidate.host === "string" && typeof candidate.pid === "number" && typeof candidate.startedAt === "string";
3658
+ }
3659
+ function formatOccupancyError(code, detail) {
3660
+ return `${code}${ERROR_DETAIL_SEPARATOR2}${detail}`;
3661
+ }
3662
+ function toErrorMessage2(error) {
3663
+ return error instanceof Error ? error.message : String(error);
3664
+ }
3665
+
3666
+ // src/domains/worktree/controlling-process.ts
3667
+ var CONTROLLING_PID_ENV = "SPX_WORKTREE_CONTROLLING_PID";
3668
+ var AGENT_RUNTIME_NAMES = ["claude", "codex"];
3669
+ var AGENT_COMMAND_PATTERN = new RegExp(`\\b(?:${AGENT_RUNTIME_NAMES.join("|")})\\b`, "i");
3670
+ var CONTROLLING_PROCESS_ERROR = {
3671
+ UNRESOLVED: "worktree controlling process could not be resolved"
3672
+ };
3673
+ var MAX_ANCESTRY_DEPTH = 64;
3674
+ var MIN_VALID_PID = 1;
3675
+ var PID_RADIX = 10;
3676
+ function resolveControllingProcess(selfPid, table, env) {
3677
+ const host = table.currentHost();
3678
+ for (const pid of controllingPidCandidates(selfPid, table, env)) {
3679
+ const startedAt = table.startTimeOf(pid);
3680
+ if (startedAt !== void 0) return { ok: true, value: { pid, startedAt, host } };
3681
+ if (table.isAlive(pid)) return { ok: true, value: { pid, startedAt: unreadableStartedAt(pid), host } };
3682
+ }
3683
+ return { ok: false, error: CONTROLLING_PROCESS_ERROR.UNRESOLVED };
3684
+ }
3685
+ function* controllingPidCandidates(selfPid, table, env) {
3686
+ const override = parsePid(env[CONTROLLING_PID_ENV]);
3687
+ if (override !== void 0) yield override;
3688
+ const agent = findAgentAncestor(selfPid, table);
3689
+ if (agent !== void 0) yield agent;
3690
+ const parent = table.parentOf(selfPid);
3691
+ if (parent !== void 0 && isValidPid(parent)) yield parent;
3692
+ }
3693
+ function findAgentAncestor(selfPid, table) {
3694
+ let pid = table.parentOf(selfPid);
3695
+ for (let depth = 0; pid !== void 0 && isValidPid(pid) && depth < MAX_ANCESTRY_DEPTH; depth += 1) {
3696
+ const command = table.commandOf(pid);
3697
+ if (command !== void 0 && AGENT_COMMAND_PATTERN.test(command)) return pid;
3698
+ pid = table.parentOf(pid);
3699
+ }
3700
+ return void 0;
3701
+ }
3702
+ function parsePid(value) {
3703
+ if (value === void 0 || value.length === 0) return void 0;
3704
+ const parsed = Number.parseInt(value, PID_RADIX);
3705
+ return isValidPid(parsed) ? parsed : void 0;
3706
+ }
3707
+ function isValidPid(pid) {
3708
+ return Number.isInteger(pid) && pid >= MIN_VALID_PID;
3709
+ }
3710
+
3711
+ // src/domains/worktree/resolve.ts
3712
+ import { dirname as dirname3, resolve as resolve3 } from "path";
3713
+
3714
+ // src/domains/worktree/worktree-name.ts
3715
+ import { basename as basename2 } from "path";
3716
+ var SEPARATOR_CHARACTER = /[^a-z0-9_]/;
3717
+ var NAME_SEPARATOR = "-";
3718
+ function worktreeClaimName(worktreeRoot) {
3719
+ return basename2(worktreeRoot).toLowerCase().split(SEPARATOR_CHARACTER).filter((segment) => segment.length > 0).join(NAME_SEPARATOR);
3720
+ }
3721
+
3722
+ // src/domains/worktree/resolve.ts
3723
+ var WORKTREE_RESOLVE_ERROR = {
3724
+ NOT_A_WORKTREE: "path resolves to no worktree"
3725
+ };
3726
+ async function resolveWorktreesDir(options) {
3727
+ if (options.worktreesDir !== void 0) return options.worktreesDir;
3728
+ const resolved = await resolveWorktreesScopeDir({ cwd: options.cwd, deps: options.gitDeps });
3729
+ options.onWarning?.(resolved.warning);
3730
+ return resolved.worktreesDir;
3731
+ }
3732
+ async function resolveCurrentWorktreeName(options) {
3733
+ const worktree = await detectWorktreeProductRoot(options.cwd, options.gitDeps);
3734
+ return worktreeClaimName(worktree.productDir);
3735
+ }
3736
+ async function resolveTargetWorktree(options) {
3737
+ const base = options.cwd;
3738
+ const targetPath = options.worktree === void 0 ? base : resolve3(base, options.worktree);
3739
+ const targetGitPath = await options.pathInfo.isExistingNonDirectory(targetPath) ? dirname3(targetPath) : targetPath;
3740
+ const worktree = await detectWorktreeProductRoot(targetGitPath, options.gitDeps);
3741
+ if (!worktree.isGitRepo) {
3742
+ return { ok: false, error: `${WORKTREE_RESOLVE_ERROR.NOT_A_WORKTREE}: ${options.worktree ?? base}` };
3743
+ }
3744
+ return { ok: true, value: { name: worktreeClaimName(worktree.productDir), worktreeRoot: worktree.productDir } };
3745
+ }
3746
+
3747
+ // src/domains/worktree/claim.ts
3748
+ async function claimWorktreeOccupancy(options) {
3749
+ const controlling = resolveControllingProcess(options.selfPid, options.processTable, options.env);
3750
+ if (!controlling.ok) return controlling;
3751
+ const worktreesDir = await resolveWorktreesDir(options);
3752
+ const name = await resolveCurrentWorktreeName(options);
3753
+ return writeClaim(
3754
+ worktreesDir,
3755
+ name,
3756
+ {
3757
+ sessionId: options.sessionId,
3758
+ host: controlling.value.host,
3759
+ pid: controlling.value.pid,
3760
+ startedAt: controlling.value.startedAt
3761
+ },
3762
+ { fs: options.fs, writeToken: options.claimWriteToken }
3763
+ );
3764
+ }
3765
+
3766
+ // src/domains/hooks/session-start.ts
3767
+ var HOOK_SESSION_START_PAYLOAD = {
3768
+ CWD: "cwd",
3769
+ SESSION_ID: "session_id"
3770
+ };
3771
+ var HOOK_SESSION_START_ENV = {
3772
+ CLAUDE_ENV_FILE: "CLAUDE_ENV_FILE",
3773
+ CLAUDE_PROJECT_DIR: "CLAUDE_PROJECT_DIR",
3774
+ CLAUDE_SESSION_ID: "CLAUDE_SESSION_ID",
3775
+ CLAUDE_WORKTREE_CLAIMED: "CLAUDE_WORKTREE_CLAIMED",
3776
+ CODEX_THREAD_ID: "CODEX_THREAD_ID",
3777
+ PROJECT_DIR: "PROJECT_DIR"
3778
+ };
3779
+ var HOOK_SESSION_START_CLAIMED = {
3780
+ FALSE: "0",
3781
+ TRUE: "1"
3782
+ };
3783
+ var HOOK_ENV_FILE = {
3784
+ ENCODING: "utf8",
3785
+ EXPORT_PREFIX: "export "
3786
+ };
3787
+ var HOOK_SESSION_START_ERROR = {
3788
+ ENV_FILE_WRITE_FAILED: "hook session-start env file write failed",
3789
+ PAYLOAD_MALFORMED: "hook session-start payload must be a JSON object"
3790
+ };
3791
+ var ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
3792
+ var SAFE_SHELL_VALUE_PATTERN = /^[A-Za-z0-9_@%+=:,.-]+$/;
3793
+ var SINGLE_QUOTE = "'";
3794
+ var SHELL_SINGLE_QUOTE_ESCAPE = `'"'"'`;
3795
+ var ENV_FILE_HEADER = "# Managed by spx hook run session-start";
3796
+ var LINE_SEPARATOR = "\n";
3797
+ function parseHookSessionStartPayload(content) {
3798
+ if (content === void 0 || content.trim().length === 0) return { ok: true, value: {} };
3799
+ let parsed;
3800
+ try {
3801
+ parsed = JSON.parse(content);
3802
+ } catch {
3803
+ return { ok: false, error: HOOK_SESSION_START_ERROR.PAYLOAD_MALFORMED };
3804
+ }
3805
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
3806
+ return { ok: false, error: HOOK_SESSION_START_ERROR.PAYLOAD_MALFORMED };
3807
+ }
3808
+ const record = parsed;
3809
+ return {
3810
+ ok: true,
3811
+ value: {
3812
+ cwd: nonEmptyString(record[HOOK_SESSION_START_PAYLOAD.CWD]),
3813
+ sessionId: nonEmptyString(record[HOOK_SESSION_START_PAYLOAD.SESSION_ID])
3814
+ }
3815
+ };
3816
+ }
3817
+ function resolveHookSessionStartSessionId(payload, env) {
3818
+ return payload.sessionId ?? nonEmptyString(env[HOOK_SESSION_START_ENV.CLAUDE_SESSION_ID]) ?? nonEmptyString(env[HOOK_SESSION_START_ENV.CODEX_THREAD_ID]);
3819
+ }
3820
+ function resolveHookSessionStartProductDir(payload, cwd) {
3821
+ return payload.cwd ?? cwd;
3822
+ }
3823
+ function resolveHookSessionStartEnvFile(env, explicitEnvFile) {
3824
+ return nonEmptyString(explicitEnvFile) ?? nonEmptyString(env[HOOK_SESSION_START_ENV.CLAUDE_ENV_FILE]);
3825
+ }
3826
+ function renderHookSessionStartEnvFile(input) {
3827
+ return [
3828
+ "",
3829
+ ENV_FILE_HEADER,
3830
+ ...input.sessionId === void 0 ? [] : [renderExportLine(HOOK_SESSION_START_ENV.CLAUDE_SESSION_ID, input.sessionId)],
3831
+ renderExportLine(HOOK_SESSION_START_ENV.CLAUDE_PROJECT_DIR, input.productDir),
3832
+ renderExportLine(HOOK_SESSION_START_ENV.PROJECT_DIR, input.productDir),
3833
+ renderExportLine(
3834
+ HOOK_SESSION_START_ENV.CLAUDE_WORKTREE_CLAIMED,
3835
+ input.claimed ? HOOK_SESSION_START_CLAIMED.TRUE : HOOK_SESSION_START_CLAIMED.FALSE
3836
+ ),
3837
+ ""
3838
+ ].join(LINE_SEPARATOR);
3839
+ }
3840
+ function renderExportLine(name, value) {
3841
+ if (!ENV_NAME_PATTERN.test(name)) throw new Error(`invalid environment variable name: ${name}`);
3842
+ return `${HOOK_ENV_FILE.EXPORT_PREFIX}${name}=${shellQuote(value)}`;
3843
+ }
3844
+ function shellQuote(value) {
3845
+ if (SAFE_SHELL_VALUE_PATTERN.test(value)) return value;
3846
+ return `${SINGLE_QUOTE}${value.replaceAll(SINGLE_QUOTE, SHELL_SINGLE_QUOTE_ESCAPE)}${SINGLE_QUOTE}`;
3847
+ }
3848
+ function nonEmptyString(value) {
3849
+ if (typeof value !== "string") return void 0;
3850
+ const trimmed = value.trim();
3851
+ return trimmed.length === 0 ? void 0 : trimmed;
3852
+ }
3853
+
3854
+ // src/interfaces/hooks/session-start.ts
3855
+ var defaultHookEnvFileSystem = {
3856
+ appendFile: async (path6, data, encoding) => {
3857
+ await nodeAppendFile2(path6, data, encoding);
3858
+ }
3859
+ };
3860
+ var ERROR_DETAIL_SEPARATOR3 = ": ";
3861
+ var EMPTY_HOOK_STDOUT = "";
3862
+ async function runSessionStartHook(options) {
3863
+ const diagnostics = [];
3864
+ const payloadResult = parseHookSessionStartPayload(options.content);
3865
+ if (!payloadResult.ok) {
3866
+ diagnostics.push(payloadResult.error);
3867
+ }
3868
+ const payload = payloadResult.ok ? payloadResult.value : {};
3869
+ const productDir = resolveHookSessionStartProductDir(payload, options.cwd);
3870
+ const sessionId = resolveHookSessionStartSessionId(payload, options.env);
3871
+ let claimed = false;
3872
+ if (sessionId !== void 0) {
3873
+ const claim = await claimWorktreeOccupancy({
3874
+ ...options,
3875
+ cwd: productDir,
3876
+ sessionId
3877
+ });
3878
+ if (claim.ok) {
3879
+ claimed = true;
3880
+ } else {
3881
+ diagnostics.push(claim.error);
3882
+ }
3883
+ }
3884
+ const envFile = resolveHookSessionStartEnvFile(options.env, options.envFile);
3885
+ const envFileWritten = await writeEnvFileIfConfigured({
3886
+ claimed,
3887
+ envFile,
3888
+ productDir,
3889
+ sessionId,
3890
+ envFileSystem: options.envFileSystem ?? defaultHookEnvFileSystem,
3891
+ diagnostics
3892
+ });
3893
+ return {
3894
+ ok: true,
3895
+ value: {
3896
+ claimed,
3897
+ diagnostics,
3898
+ envFileWritten,
3899
+ productDir,
3900
+ stdout: EMPTY_HOOK_STDOUT,
3901
+ ...sessionId === void 0 ? {} : { sessionId }
3902
+ }
3903
+ };
3904
+ }
3905
+ async function writeEnvFileIfConfigured(options) {
3906
+ if (options.envFile === void 0) return false;
3907
+ try {
3908
+ await options.envFileSystem.appendFile(
3909
+ options.envFile,
3910
+ renderHookSessionStartEnvFile({
3911
+ claimed: options.claimed,
3912
+ productDir: options.productDir,
3913
+ sessionId: options.sessionId
3914
+ }),
3915
+ HOOK_ENV_FILE.ENCODING
3916
+ );
3917
+ return true;
3918
+ } catch (error) {
3919
+ options.diagnostics.push(
3920
+ `${HOOK_SESSION_START_ERROR.ENV_FILE_WRITE_FAILED}${ERROR_DETAIL_SEPARATOR3}${error instanceof Error ? error.message : String(error)}`
3921
+ );
3922
+ return false;
3923
+ }
3924
+ }
3925
+
3926
+ // src/interfaces/hooks/registry.ts
3927
+ var HOOK_EVENT = {
3928
+ SESSION_START: "session-start"
3929
+ };
3930
+ var HOOK_ERROR = {
3931
+ UNKNOWN_EVENT: "unknown hook event"
3932
+ };
3933
+ function isHookEvent(event) {
3934
+ return Object.values(HOOK_EVENT).includes(event);
3935
+ }
3936
+ async function runHookEvent(options) {
3937
+ if (options.event === HOOK_EVENT.SESSION_START) return runSessionStartHook(options);
3938
+ return { ok: false, error: `${HOOK_ERROR.UNKNOWN_EVENT}: ${options.event}` };
3939
+ }
3940
+
3941
+ // src/interfaces/hooks/cli-runner.ts
3942
+ var HOOK_PROCESS_IO_EVENT = {
3943
+ DATA: "data",
3944
+ END: "end",
3945
+ ERROR: "error"
3946
+ };
3947
+ var LINE_SEPARATOR2 = "\n";
3948
+ var ERROR_DETAIL_SEPARATOR4 = ": ";
3949
+ var STDIN_READ_ERROR = "hook stdin read failed";
3950
+ async function runHookCli(options) {
3951
+ if (!isHookEvent(options.event)) {
3952
+ options.io.writeStderr(`${HOOK_ERROR.UNKNOWN_EVENT}: ${sanitizeCliArgument(options.event)}`);
3953
+ return { ok: false, error: HOOK_ERROR.UNKNOWN_EVENT };
3954
+ }
3955
+ const diagnostics = [];
3956
+ const stdin = await options.io.readStdin();
3957
+ const content = stdin.ok ? stdin.value : void 0;
3958
+ if (!stdin.ok) diagnostics.push(stdin.error);
3959
+ const runEvent = options.runEvent ?? runHookEvent;
3960
+ const result = await runEvent({
3961
+ claimWriteToken: options.claimWriteToken,
3962
+ content,
3963
+ cwd: options.cwd,
3964
+ env: options.env,
3965
+ envFile: options.envFile,
3966
+ event: options.event,
3967
+ fs: options.fs,
3968
+ gitDeps: options.gitDeps,
3969
+ onWarning: options.onWarning,
3970
+ processTable: options.processTable,
3971
+ selfPid: options.selfPid,
3972
+ worktreesDir: options.worktreesDir
3973
+ });
3974
+ for (const diagnostic of diagnostics) {
3975
+ options.io.writeStderr(diagnostic);
3976
+ }
3977
+ if (!result.ok) {
3978
+ options.io.writeStderr(result.error);
3979
+ return result;
3980
+ }
3981
+ for (const diagnostic of result.value.diagnostics) {
3982
+ options.io.writeStderr(diagnostic);
3983
+ }
3984
+ if (result.value.stdout.length > 0) {
3985
+ options.io.writeStdout(result.value.stdout);
3986
+ }
3987
+ return { ok: true, value: void 0 };
3988
+ }
3989
+ function createProcessHookIo(streams) {
3990
+ return {
3991
+ readStdin: async () => {
3992
+ if (streams.stdin.isTTY) return { ok: true, value: void 0 };
3993
+ return new Promise((resolve6) => {
3994
+ let data = "";
3995
+ streams.stdin.setEncoding("utf-8");
3996
+ streams.stdin.on(HOOK_PROCESS_IO_EVENT.DATA, (chunk) => {
3997
+ data += chunk;
3998
+ });
3999
+ streams.stdin.on(HOOK_PROCESS_IO_EVENT.END, () => {
4000
+ resolve6({ ok: true, value: data.length === 0 ? void 0 : data });
4001
+ });
4002
+ streams.stdin.on(HOOK_PROCESS_IO_EVENT.ERROR, (error) => {
4003
+ resolve6({ ok: false, error: formatStdinReadError(error) });
4004
+ });
4005
+ });
4006
+ },
4007
+ writeStdout: (content) => {
4008
+ streams.stdout.write(`${content}${LINE_SEPARATOR2}`);
4009
+ },
4010
+ writeStderr: (content) => {
4011
+ streams.stderr.write(`${content}${LINE_SEPARATOR2}`);
4012
+ }
4013
+ };
4014
+ }
4015
+ function formatStdinReadError(error) {
4016
+ return `${STDIN_READ_ERROR}${ERROR_DETAIL_SEPARATOR4}${error instanceof Error ? error.message : String(error)}`;
4017
+ }
4018
+ var processHookIo = createProcessHookIo({
4019
+ stdin: process.stdin,
4020
+ stdout: process.stdout,
4021
+ stderr: process.stderr
4022
+ });
4023
+
4024
+ // src/lib/worktree-claim-write-token.ts
4025
+ import { randomBytes as nodeRandomBytes2 } from "crypto";
4026
+ var CLAIM_WRITE_TOKEN_BYTES = 8;
4027
+ function createClaimWriteToken(randomBytes = nodeRandomBytes2) {
4028
+ return randomBytes(CLAIM_WRITE_TOKEN_BYTES).toString("hex");
4029
+ }
4030
+
4031
+ // src/lib/worktree-occupancy-file-system.ts
4032
+ import {
4033
+ mkdir as nodeMkdir2,
4034
+ readFile as nodeReadFile2,
4035
+ rename as nodeRename,
4036
+ rm as nodeRm,
4037
+ writeFile as nodeWriteFile2
4038
+ } from "fs/promises";
4039
+ var defaultOccupancyFileSystem = {
4040
+ mkdir: async (path6, options) => {
4041
+ await nodeMkdir2(path6, options);
4042
+ },
4043
+ writeFile: async (path6, data) => {
4044
+ await nodeWriteFile2(path6, data);
4045
+ },
4046
+ rename: nodeRename,
4047
+ readFile: nodeReadFile2,
4048
+ rm: async (path6, options) => {
4049
+ await nodeRm(path6, options);
4050
+ }
4051
+ };
4052
+
4053
+ // src/lib/worktree-process-table.ts
4054
+ import { spawnSync } from "child_process";
4055
+ import { hostname } from "os";
4056
+ var PS_COMMAND = "/bin/ps";
4057
+ var PS_FIELD = {
4058
+ START_TIME: "lstart",
4059
+ PARENT_PID: "ppid",
4060
+ // The full command line, not `comm`: interpreted agent runtimes otherwise
4061
+ // report only the interpreter basename and miss the agent-command match.
4062
+ COMMAND: "args"
4063
+ };
4064
+ var SIGNAL_LIVENESS_PROBE = 0;
4065
+ var PROCESS_EXISTS_NO_PERMISSION = "EPERM";
4066
+ var PID_RADIX2 = 10;
4067
+ var STABLE_PS_ENV = { ...process.env, TZ: "UTC", LC_ALL: "C" };
4068
+ function psField(pid, field) {
4069
+ const result = spawnSync(PS_COMMAND, ["-o", `${field}=`, "-p", String(pid)], {
4070
+ encoding: "utf8",
4071
+ env: STABLE_PS_ENV
4072
+ });
4073
+ if (result.status !== 0 || typeof result.stdout !== "string") return void 0;
4074
+ const value = result.stdout.trim();
4075
+ return value.length > 0 ? value : void 0;
4076
+ }
4077
+ var defaultProcessTable = {
4078
+ currentHost: () => hostname(),
4079
+ isAlive: (pid) => {
4080
+ try {
4081
+ process.kill(pid, SIGNAL_LIVENESS_PROBE);
4082
+ return true;
4083
+ } catch (error) {
4084
+ return hasErrorCode(error, PROCESS_EXISTS_NO_PERMISSION);
4085
+ }
4086
+ },
4087
+ startTimeOf: (pid) => psField(pid, PS_FIELD.START_TIME),
4088
+ parentOf: (pid) => {
4089
+ const value = psField(pid, PS_FIELD.PARENT_PID);
4090
+ if (value === void 0) return void 0;
4091
+ const parsed = Number.parseInt(value, PID_RADIX2);
4092
+ return Number.isInteger(parsed) ? parsed : void 0;
4093
+ },
4094
+ commandOf: (pid) => psField(pid, PS_FIELD.COMMAND)
4095
+ };
4096
+
4097
+ // src/interfaces/cli/hook.ts
4098
+ var HOOK_CLI = {
4099
+ COMMAND: "hook",
4100
+ RUN: "run",
4101
+ EVENT_ARGUMENT: "<event>",
4102
+ ENV_FILE_FLAG: "--hook-env-file",
4103
+ WORKTREES_DIR_FLAG: "--worktrees-dir"
4104
+ };
4105
+ var HOOK_DOMAIN_DESCRIPTION = "Run host lifecycle hook events";
4106
+ function registerHookCommands(hookCmd) {
4107
+ hookCmd.command(`${HOOK_CLI.RUN} ${HOOK_CLI.EVENT_ARGUMENT}`).description("Run a hook lifecycle event").option(`${HOOK_CLI.ENV_FILE_FLAG} <path>`, "Hook env file to append; defaults to $CLAUDE_ENV_FILE").option(`${HOOK_CLI.WORKTREES_DIR_FLAG} <path>`, "Explicit .spx/worktrees directory").action(async (event, options) => {
4108
+ const result = await runHookCli({
4109
+ claimWriteToken: createClaimWriteToken(),
4110
+ cwd: process.cwd(),
4111
+ env: process.env,
4112
+ envFile: options.hookEnvFile,
4113
+ event,
4114
+ fs: defaultOccupancyFileSystem,
4115
+ gitDeps: defaultGitDependencies,
4116
+ io: processHookIo,
4117
+ onWarning: writeWarning,
4118
+ processTable: defaultProcessTable,
4119
+ selfPid: process.pid,
4120
+ worktreesDir: options.worktreesDir
4121
+ });
4122
+ if (!result.ok) process.exit(1);
4123
+ });
4124
+ }
4125
+ var hookDomain = {
4126
+ name: HOOK_CLI.COMMAND,
4127
+ description: HOOK_DOMAIN_DESCRIPTION,
4128
+ register: (program2) => {
4129
+ const hookCmd = program2.command(HOOK_CLI.COMMAND).description(HOOK_DOMAIN_DESCRIPTION);
4130
+ registerHookCommands(hookCmd);
4131
+ }
4132
+ };
4133
+
4134
+ // src/commands/session/archive.ts
4135
+ import { mkdir, rename, stat } from "fs/promises";
4136
+ import { dirname as dirname4, join as join8 } from "path";
4137
+
4138
+ // src/domains/session/archive.ts
4139
+ var SESSION_FILE_EXTENSION = ".md";
4140
+ var ARCHIVABLE_DIR_KEYS = {
4141
+ TODO: "todoDir",
4142
+ DOING: "doingDir"
4143
+ };
4144
+ var ARCHIVABLE_DIR_KEY = {
4145
+ todo: ARCHIVABLE_DIR_KEYS.TODO,
4146
+ doing: ARCHIVABLE_DIR_KEYS.DOING
4147
+ };
4148
+ function buildArchivePaths(sessionId, currentStatus, config) {
4149
+ const filename = `${sessionId}${SESSION_FILE_EXTENSION}`;
4150
+ const dirKey = ARCHIVABLE_DIR_KEY[currentStatus];
4151
+ const sourceDir = config[dirKey];
4152
+ return {
4153
+ source: `${sourceDir}/${filename}`,
4154
+ target: `${config.archiveDir}/${filename}`
4155
+ };
4156
+ }
4157
+ var ARCHIVE_SEARCH_ORDER = ["todo", "doing"];
4158
+ function findSessionForArchive(existingPaths) {
4159
+ if (existingPaths.archive !== null) {
4160
+ return null;
4161
+ }
4162
+ for (const status of ARCHIVE_SEARCH_ORDER) {
4163
+ if (existingPaths[status] !== null) {
4164
+ return { status, path: existingPaths[status] };
4165
+ }
4166
+ }
4167
+ return null;
4168
+ }
4169
+
4170
+ // src/domains/session/batch.ts
4171
+ var BatchError = class extends Error {
4172
+ results;
4173
+ constructor(results) {
4174
+ const failures = results.filter((r) => !r.ok);
4175
+ const successes = results.filter((r) => r.ok);
4176
+ super(
4177
+ `${failures.length} of ${results.length} operations failed. ${successes.length} succeeded.`
4178
+ );
4179
+ this.name = "BatchError";
4180
+ this.results = results;
4181
+ }
4182
+ };
4183
+ async function processBatch(ids, handler) {
4184
+ const results = [];
4185
+ for (const id of ids) {
4186
+ try {
4187
+ const output2 = await handler(id);
4188
+ results.push({ id, ok: true, message: output2 });
4189
+ } catch (error) {
4190
+ const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
4191
+ results.push({ id, ok: false, message });
4192
+ }
4193
+ }
4194
+ const output = results.map((r) => r.ok ? r.message : `Error (${r.id}): ${r.message}`).join("\n\n");
4195
+ const hasFailures = results.some((r) => !r.ok);
4196
+ if (hasFailures) {
4197
+ const err = new BatchError(results);
4198
+ err.message = `${err.message}
4199
+
4200
+ ${output}`;
4201
+ throw err;
4202
+ }
4203
+ return output;
4204
+ }
4205
+
4206
+ // src/domains/session/handoff-base-checklist.ts
4207
+ var SESSION_HANDOFF_BASE_ERROR_NAME = "SessionHandoffBaseError";
4208
+ var HANDOFF_BASE_MARK = {
4209
+ MET: "\u2713",
4210
+ UNMET: "\u2717"
4211
+ };
4212
+ var HANDOFF_BASE_PREREQUISITE_LABEL = {
4213
+ CLEAN_WORKING_TREE: "working tree is clean",
4214
+ DETACHED_AT_DEFAULT_TIP: "HEAD is detached at the default-branch tip"
4215
+ };
4216
+ var HANDOFF_BASE_REMEDY = {
4217
+ /** Unclean working tree: commit, or hand off from the main checkout. */
4218
+ COMMIT_OR_MAIN_CHECKOUT: "commit the changes, or run handoff from the main checkout",
4219
+ /** Off the default-branch tip: detach to it, or hand off from the main checkout. */
4220
+ DETACH_TO_TIP_OR_MAIN_CHECKOUT: "detach HEAD to the default-branch tip, or run handoff from the main checkout",
4221
+ /** Default branch unresolved: only the main checkout can anchor the base. */
4222
+ MAIN_CHECKOUT_ONLY: "run handoff from the main checkout"
4223
+ };
4224
+ var HANDOFF_BASE_FACT_LABEL = {
4225
+ DEFAULT_BRANCH: "default branch",
4226
+ DEFAULT_TIP: "origin tip",
4227
+ HEAD: "HEAD",
4228
+ CURRENT_WORKTREE: "current worktree",
4229
+ MAIN_CHECKOUT: "main checkout"
4230
+ };
4231
+ var HANDOFF_BASE_UNRESOLVED = "unresolved";
4232
+ var CHECKLIST_INDENT = " ";
4233
+ var REMEDY_SEPARATOR = " \u2014 ";
4234
+ var CHECKLIST_HEADER = `${SESSION_HANDOFF_BASE_ERROR_NAME}: cannot create a handoff session from this worktree \u2014 it is not the main checkout.`;
4235
+ function renderFactLine(label, value) {
4236
+ return `${CHECKLIST_INDENT}${label}: ${value ?? HANDOFF_BASE_UNRESOLVED}`;
4237
+ }
4238
+ function renderPrerequisiteLine(prerequisite) {
4239
+ const mark = prerequisite.met ? HANDOFF_BASE_MARK.MET : HANDOFF_BASE_MARK.UNMET;
4240
+ const base = `${CHECKLIST_INDENT}${mark} ${prerequisite.label}`;
4241
+ return prerequisite.met ? base : `${base}${REMEDY_SEPARATOR}${prerequisite.remedy}`;
4242
+ }
4243
+ function renderHandoffBaseChecklist(checklist) {
4244
+ return [
4245
+ CHECKLIST_HEADER,
4246
+ renderFactLine(HANDOFF_BASE_FACT_LABEL.DEFAULT_BRANCH, checklist.defaultBranch),
4247
+ renderFactLine(HANDOFF_BASE_FACT_LABEL.DEFAULT_TIP, checklist.defaultTipSha),
4248
+ renderFactLine(HANDOFF_BASE_FACT_LABEL.HEAD, checklist.headSha),
4249
+ renderFactLine(HANDOFF_BASE_FACT_LABEL.CURRENT_WORKTREE, checklist.currentWorktreePath),
4250
+ renderFactLine(HANDOFF_BASE_FACT_LABEL.MAIN_CHECKOUT, checklist.mainCheckoutPath),
4251
+ ...checklist.prerequisites.map(renderPrerequisiteLine)
4252
+ ].join("\n");
4253
+ }
4254
+
4255
+ // src/domains/session/errors.ts
4256
+ var SessionError = class extends Error {
4257
+ constructor(message) {
4258
+ super(message);
4259
+ this.name = "SessionError";
4260
+ }
4261
+ };
4262
+ var SessionNotFoundError = class extends SessionError {
4263
+ /** The session ID that was not found */
4264
+ sessionId;
4265
+ constructor(sessionId) {
4266
+ super(`Session not found: ${sessionId}. Check the session ID and try again.`);
4267
+ this.name = "SessionNotFoundError";
4268
+ this.sessionId = sessionId;
4269
+ }
4270
+ };
4271
+ var SessionNotAvailableError = class extends SessionError {
4272
+ /** The session ID that is not available */
4273
+ sessionId;
4274
+ constructor(sessionId) {
4275
+ super(`Session not available: ${sessionId}. It may have been claimed by another agent.`);
4276
+ this.name = "SessionNotAvailableError";
4277
+ this.sessionId = sessionId;
4278
+ }
4279
+ };
4280
+ var SessionInvalidContentError = class extends SessionError {
4281
+ constructor(reason) {
4282
+ super(`Invalid session content: ${reason}`);
4283
+ this.name = "SessionInvalidContentError";
4284
+ }
4285
+ };
4286
+ var SessionInvalidGoalError = class extends SessionError {
4287
+ constructor() {
4288
+ super("Invalid session goal: goal must be a non-empty string.");
4289
+ this.name = "SessionInvalidGoalError";
4290
+ }
4291
+ };
4292
+ var SessionInvalidFieldError = class extends SessionError {
4293
+ /** The unknown field token the caller supplied. */
4294
+ field;
4295
+ /** The full set of valid field names. */
4296
+ validFields;
3665
4297
  constructor(field, validFields) {
3666
4298
  super(`Invalid field: "${field}". Valid fields: ${validFields.join(", ")}`);
3667
4299
  this.name = "SessionInvalidFieldError";
@@ -3733,7 +4365,7 @@ var SessionInvalidJsonHeaderError = class extends SessionError {
3733
4365
  };
3734
4366
 
3735
4367
  // src/commands/session/resolve-config.ts
3736
- import { join as join6 } from "path";
4368
+ import { join as join7 } from "path";
3737
4369
 
3738
4370
  // src/config/defaults.ts
3739
4371
  var DEFAULT_CONFIG = {
@@ -3753,18 +4385,18 @@ async function resolveSessionConfig(options = {}) {
3753
4385
  if (sessionsDir) {
3754
4386
  return {
3755
4387
  config: {
3756
- todoDir: join6(sessionsDir, statusDirs2.todo),
3757
- doingDir: join6(sessionsDir, statusDirs2.doing),
3758
- archiveDir: join6(sessionsDir, statusDirs2.archive)
4388
+ todoDir: join7(sessionsDir, statusDirs2.todo),
4389
+ doingDir: join7(sessionsDir, statusDirs2.doing),
4390
+ archiveDir: join7(sessionsDir, statusDirs2.archive)
3759
4391
  }
3760
4392
  };
3761
4393
  }
3762
4394
  const { sessionsDir: baseDir, warning } = await resolveSessionsScopeDir({ cwd, deps });
3763
4395
  return {
3764
4396
  config: {
3765
- todoDir: join6(baseDir, statusDirs2.todo),
3766
- doingDir: join6(baseDir, statusDirs2.doing),
3767
- archiveDir: join6(baseDir, statusDirs2.archive)
4397
+ todoDir: join7(baseDir, statusDirs2.todo),
4398
+ doingDir: join7(baseDir, statusDirs2.doing),
4399
+ archiveDir: join7(baseDir, statusDirs2.archive)
3768
4400
  },
3769
4401
  warning
3770
4402
  };
@@ -3801,9 +4433,9 @@ async function fileExists(path6) {
3801
4433
  }
3802
4434
  async function probeSessionPaths(sessionId, config) {
3803
4435
  const filename = `${sessionId}${SESSION_FILE_EXTENSION}`;
3804
- const todoPath = join7(config.todoDir, filename);
3805
- const doingPath = join7(config.doingDir, filename);
3806
- const archivePath = join7(config.archiveDir, filename);
4436
+ const todoPath = join8(config.todoDir, filename);
4437
+ const doingPath = join8(config.doingDir, filename);
4438
+ const archivePath = join8(config.archiveDir, filename);
3807
4439
  return {
3808
4440
  todo: await fileExists(todoPath) ? todoPath : null,
3809
4441
  doing: await fileExists(doingPath) ? doingPath : null,
@@ -3824,7 +4456,7 @@ async function resolveArchivePaths(sessionId, config) {
3824
4456
  }
3825
4457
  async function archiveSingle(sessionId, config) {
3826
4458
  const { source, target } = await resolveArchivePaths(sessionId, config);
3827
- await mkdir(dirname3(target), { recursive: true });
4459
+ await mkdir(dirname4(target), { recursive: true });
3828
4460
  await rename(source, target);
3829
4461
  return `${SESSION_ARCHIVE_OUTPUT.ARCHIVED}: ${sessionId}
3830
4462
  ${SESSION_ARCHIVE_OUTPUT.ARCHIVE_LOCATION}: ${target}`;
@@ -3847,7 +4479,7 @@ function resolveDeletePath(sessionId, existingPaths) {
3847
4479
  }
3848
4480
 
3849
4481
  // src/domains/session/show.ts
3850
- import { join as join8 } from "path";
4482
+ import { join as join9 } from "path";
3851
4483
 
3852
4484
  // src/domains/session/list.ts
3853
4485
  import { parse as parseYaml2 } from "yaml";
@@ -3898,6 +4530,7 @@ var SESSION_PRIORITY = {
3898
4530
  };
3899
4531
  var SESSION_STATUSES = ["todo", "doing", "archive"];
3900
4532
  var DEFAULT_LIST_STATUSES = ["doing", "todo"];
4533
+ var CLAIMABLE_STATUS = SESSION_STATUSES[0];
3901
4534
  var PRIORITY_ORDER = {
3902
4535
  [SESSION_PRIORITY.HIGH]: 0,
3903
4536
  [SESSION_PRIORITY.MEDIUM]: 1,
@@ -4064,12 +4697,12 @@ var SESSION_SHOW_LABEL = {
4064
4697
  };
4065
4698
  var SESSION_SHOW_SEPARATOR_CHAR = "\u2500";
4066
4699
  var SESSION_SHOW_SEPARATOR_WIDTH = 40;
4067
- var sessionsBaseDir = join8(STATE_STORE_PATH.SPX_DIR, STATE_STORE_PATH.SESSIONS_SCOPE);
4700
+ var sessionsBaseDir = join9(STATE_STORE_PATH.SPX_DIR, STATE_STORE_PATH.SESSIONS_SCOPE);
4068
4701
  var { statusDirs } = DEFAULT_CONFIG.sessions;
4069
4702
  var DEFAULT_SESSION_CONFIG = {
4070
- todoDir: join8(sessionsBaseDir, statusDirs.todo),
4071
- doingDir: join8(sessionsBaseDir, statusDirs.doing),
4072
- archiveDir: join8(sessionsBaseDir, statusDirs.archive)
4703
+ todoDir: join9(sessionsBaseDir, statusDirs.todo),
4704
+ doingDir: join9(sessionsBaseDir, statusDirs.doing),
4705
+ archiveDir: join9(sessionsBaseDir, statusDirs.archive)
4073
4706
  };
4074
4707
  var SEARCH_ORDER = [...SESSION_STATUSES];
4075
4708
  function resolveSessionPaths(id, config = DEFAULT_SESSION_CONFIG) {
@@ -4127,7 +4760,7 @@ async function deleteCommand(options) {
4127
4760
 
4128
4761
  // src/commands/session/handoff.ts
4129
4762
  import { mkdir as mkdir2, writeFile } from "fs/promises";
4130
- import { join as join9, resolve as resolve3 } from "path";
4763
+ import { join as join10, resolve as resolve4 } from "path";
4131
4764
  import { stringify as stringifyYaml3 } from "yaml";
4132
4765
 
4133
4766
  // src/domains/session/create.ts
@@ -4405,8 +5038,8 @@ async function handoffCommand(options) {
4405
5038
  const yaml = stringifyYaml3(frontMatterObject, { defaultStringType: "QUOTE_DOUBLE" }).trimEnd();
4406
5039
  const fullContent = `${SESSION_FRONT_MATTER_OPEN}${yaml}${SESSION_FRONT_MATTER_CLOSE}${body}`;
4407
5040
  const filename = `${sessionId}.md`;
4408
- const sessionPath = join9(config.todoDir, filename);
4409
- const absolutePath = resolve3(sessionPath);
5041
+ const sessionPath = join10(config.todoDir, filename);
5042
+ const absolutePath = resolve4(sessionPath);
4410
5043
  await mkdir2(config.todoDir, { recursive: true });
4411
5044
  await writeFile(sessionPath, fullContent, "utf-8");
4412
5045
  const output = `Created handoff session <HANDOFF_ID>${sessionId}</HANDOFF_ID>
@@ -4416,7 +5049,7 @@ async function handoffCommand(options) {
4416
5049
 
4417
5050
  // src/commands/session/list.ts
4418
5051
  import { readdir, readFile as readFile3 } from "fs/promises";
4419
- import { join as join10 } from "path";
5052
+ import { join as join11 } from "path";
4420
5053
  var SESSION_LIST_FORMAT = {
4421
5054
  TEXT: "text",
4422
5055
  JSON: "json"
@@ -4430,7 +5063,7 @@ async function loadSessionsFromDir(dir, status) {
4430
5063
  for (const file of files) {
4431
5064
  if (!file.endsWith(".md")) continue;
4432
5065
  const id = file.replace(".md", "");
4433
- const filePath = join10(dir, file);
5066
+ const filePath = join11(dir, file);
4434
5067
  const content = await readFile3(filePath, "utf-8");
4435
5068
  const metadata = parseSessionMetadata(content);
4436
5069
  sessions.push({
@@ -4503,7 +5136,7 @@ async function listCommand(options) {
4503
5136
 
4504
5137
  // src/commands/session/pickup.ts
4505
5138
  import { mkdir as mkdir3, readdir as readdir2, readFile as readFile4, rename as rename2 } from "fs/promises";
4506
- import { join as join11 } from "path";
5139
+ import { join as join12 } from "path";
4507
5140
 
4508
5141
  // src/domains/session/pickup.ts
4509
5142
  function buildClaimPaths(sessionId, config) {
@@ -4539,7 +5172,6 @@ function selectBestSession(sessions) {
4539
5172
  }
4540
5173
 
4541
5174
  // src/commands/session/pickup.ts
4542
- var PICKUP_SOURCE_STATUS = SESSION_STATUSES[0];
4543
5175
  var PICKUP_TARGET_STATUS = SESSION_STATUSES[1];
4544
5176
  async function loadTodoSessions(config) {
4545
5177
  try {
@@ -4548,12 +5180,12 @@ async function loadTodoSessions(config) {
4548
5180
  for (const file of files) {
4549
5181
  if (!file.endsWith(".md")) continue;
4550
5182
  const id = file.replace(".md", "");
4551
- const filePath = join11(config.todoDir, file);
5183
+ const filePath = join12(config.todoDir, file);
4552
5184
  const content = await readFile4(filePath, "utf-8");
4553
5185
  const metadata = parseSessionMetadata(content);
4554
5186
  sessions.push({
4555
5187
  id,
4556
- status: PICKUP_SOURCE_STATUS,
5188
+ status: CLAIMABLE_STATUS,
4557
5189
  path: filePath,
4558
5190
  metadata
4559
5191
  });
@@ -4602,9 +5234,15 @@ async function pickupCommand(options) {
4602
5234
  return processBatch(options.sessionIds, (id) => pickupSingle(id, config));
4603
5235
  }
4604
5236
 
5237
+ // src/commands/session/pick.ts
5238
+ async function loadPickCandidates(options) {
5239
+ const config = await resolveSessionConfigSurfacingWarning(options.sessionsDir, options.onWarning);
5240
+ return loadTodoSessions(config);
5241
+ }
5242
+
4605
5243
  // src/commands/session/prune.ts
4606
5244
  import { readdir as readdir3, readFile as readFile5, unlink as unlink2 } from "fs/promises";
4607
- import { join as join12 } from "path";
5245
+ import { join as join13 } from "path";
4608
5246
 
4609
5247
  // src/domains/session/prune.ts
4610
5248
  var DEFAULT_KEEP_COUNT = 5;
@@ -4653,7 +5291,7 @@ async function loadArchiveSessions(config) {
4653
5291
  for (const file of files) {
4654
5292
  if (!file.endsWith(".md")) continue;
4655
5293
  const id = file.replace(".md", "");
4656
- const filePath = join12(config.archiveDir, file);
5294
+ const filePath = join13(config.archiveDir, file);
4657
5295
  const content = await readFile5(filePath, "utf-8");
4658
5296
  const metadata = parseSessionMetadata(content);
4659
5297
  sessions.push({
@@ -4871,6 +5509,190 @@ Output:
4871
5509
  <PICKUP_ID>session-id</PICKUP_ID> tag for each claimed session
4872
5510
  `;
4873
5511
 
5512
+ // src/interfaces/cli/session/pick/run-picker.tsx
5513
+ import { render } from "ink";
5514
+
5515
+ // src/interfaces/cli/session/pick/SessionPicker.tsx
5516
+ import { Box, Text, useInput } from "ink";
5517
+ import { useState } from "react";
5518
+
5519
+ // src/domains/session/pick-model.ts
5520
+ var PICKER_ACTION = {
5521
+ MOVE: "move",
5522
+ FILTER_APPEND: "filter-append",
5523
+ FILTER_DELETE: "filter-delete",
5524
+ CLAIM: "claim",
5525
+ CANCEL: "cancel"
5526
+ };
5527
+ function buildCandidates(sessions) {
5528
+ const claimable = sessions.filter((session) => session.status === CLAIMABLE_STATUS);
5529
+ return sortSessions(claimable);
5530
+ }
5531
+ function filterCandidates(candidates, query) {
5532
+ const needle = query.trim().toLowerCase();
5533
+ if (needle.length === 0) {
5534
+ return [...candidates];
5535
+ }
5536
+ return candidates.filter((session) => {
5537
+ const haystack = `${session.id}
5538
+ ${session.metadata.goal}
5539
+ ${session.metadata.next_step}`.toLowerCase();
5540
+ return haystack.includes(needle);
5541
+ });
5542
+ }
5543
+ function visibleCandidates(state) {
5544
+ return filterCandidates(state.candidates, state.query);
5545
+ }
5546
+ function selectedSession(state) {
5547
+ const visible = visibleCandidates(state);
5548
+ return visible[state.selectedIndex] ?? null;
5549
+ }
5550
+ function initialPickerState(sessions) {
5551
+ return { candidates: buildCandidates(sessions), query: "", selectedIndex: 0 };
5552
+ }
5553
+ function keyToAction(key) {
5554
+ if (key.downArrow) return { type: PICKER_ACTION.MOVE, delta: 1 };
5555
+ if (key.upArrow) return { type: PICKER_ACTION.MOVE, delta: -1 };
5556
+ if (key.return) return { type: PICKER_ACTION.CLAIM };
5557
+ if (key.escape) return { type: PICKER_ACTION.CANCEL };
5558
+ if (key.backspace || key.delete) return { type: PICKER_ACTION.FILTER_DELETE };
5559
+ if (key.input.length > 0) return { type: PICKER_ACTION.FILTER_APPEND, char: key.input };
5560
+ return null;
5561
+ }
5562
+ function clampIndex(index, count) {
5563
+ const upper = Math.max(0, count - 1);
5564
+ if (index < 0) return 0;
5565
+ if (index > upper) return upper;
5566
+ return index;
5567
+ }
5568
+ function reducePicker(state, action) {
5569
+ switch (action.type) {
5570
+ case PICKER_ACTION.MOVE: {
5571
+ const count = visibleCandidates(state).length;
5572
+ return { ...state, selectedIndex: clampIndex(state.selectedIndex + action.delta, count) };
5573
+ }
5574
+ case PICKER_ACTION.FILTER_APPEND: {
5575
+ const query = state.query + action.char;
5576
+ const count = filterCandidates(state.candidates, query).length;
5577
+ return { ...state, query, selectedIndex: clampIndex(state.selectedIndex, count) };
5578
+ }
5579
+ case PICKER_ACTION.FILTER_DELETE: {
5580
+ const query = state.query.slice(0, -1);
5581
+ const count = filterCandidates(state.candidates, query).length;
5582
+ return { ...state, query, selectedIndex: clampIndex(state.selectedIndex, count) };
5583
+ }
5584
+ case PICKER_ACTION.CLAIM:
5585
+ case PICKER_ACTION.CANCEL:
5586
+ return state;
5587
+ }
5588
+ }
5589
+
5590
+ // src/interfaces/cli/session/pick/SessionPicker.tsx
5591
+ import { jsx, jsxs } from "react/jsx-runtime";
5592
+ var SESSION_PICKER_EMPTY_TEXT = "No claimable sessions.";
5593
+ var PRIORITY_COLOR = {
5594
+ [SESSION_PRIORITY.HIGH]: "red",
5595
+ [SESSION_PRIORITY.MEDIUM]: "yellow",
5596
+ [SESSION_PRIORITY.LOW]: "gray"
5597
+ };
5598
+ function toPickerKey(input, key) {
5599
+ return {
5600
+ input,
5601
+ upArrow: key.upArrow,
5602
+ downArrow: key.downArrow,
5603
+ return: key.return,
5604
+ escape: key.escape,
5605
+ backspace: key.backspace,
5606
+ delete: key.delete
5607
+ };
5608
+ }
5609
+ function SessionRow({ session, selected }) {
5610
+ const priority = session.metadata.priority;
5611
+ const marker = selected ? "\u276F" : " ";
5612
+ const badge = priority === DEFAULT_PRIORITY ? "" : ` [${priority}]`;
5613
+ return /* @__PURE__ */ jsxs(Text, { color: selected ? "cyan" : void 0, children: [
5614
+ marker,
5615
+ " ",
5616
+ session.id,
5617
+ /* @__PURE__ */ jsx(Text, { color: PRIORITY_COLOR[priority], children: badge }),
5618
+ " ",
5619
+ session.metadata.goal
5620
+ ] });
5621
+ }
5622
+ function PreviewPane({ session }) {
5623
+ if (session === null) {
5624
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", marginTop: 1, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: SESSION_PICKER_EMPTY_TEXT }) });
5625
+ }
5626
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginTop: 1, children: [
5627
+ /* @__PURE__ */ jsxs(Text, { children: [
5628
+ /* @__PURE__ */ jsx(Text, { bold: true, children: "goal:" }),
5629
+ session.metadata.goal
5630
+ ] }),
5631
+ /* @__PURE__ */ jsxs(Text, { children: [
5632
+ /* @__PURE__ */ jsx(Text, { bold: true, children: "next:" }),
5633
+ session.metadata.next_step
5634
+ ] })
5635
+ ] });
5636
+ }
5637
+ function SessionPicker({ sessions, onClaim, onCancel }) {
5638
+ const [state, setState] = useState(() => initialPickerState(sessions));
5639
+ useInput((input, key) => {
5640
+ const action = keyToAction(toPickerKey(input, key));
5641
+ if (action === null) return;
5642
+ if (action.type === PICKER_ACTION.CLAIM) {
5643
+ const selected2 = selectedSession(state);
5644
+ if (selected2 !== null) onClaim(selected2);
5645
+ return;
5646
+ }
5647
+ if (action.type === PICKER_ACTION.CANCEL) {
5648
+ onCancel();
5649
+ return;
5650
+ }
5651
+ setState((previous) => reducePicker(previous, action));
5652
+ });
5653
+ const visible = visibleCandidates(state);
5654
+ const selected = selectedSession(state);
5655
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
5656
+ /* @__PURE__ */ jsxs(Text, { children: [
5657
+ /* @__PURE__ */ jsx(Text, { bold: true, children: "Pick a session to claim" }),
5658
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "(type to filter, \u2191\u2193 to move, \u23CE to claim, esc to cancel)" })
5659
+ ] }),
5660
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
5661
+ "filter: ",
5662
+ state.query
5663
+ ] }),
5664
+ visible.length === 0 ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: SESSION_PICKER_EMPTY_TEXT }) : visible.map((session, index) => /* @__PURE__ */ jsx(SessionRow, { session, selected: index === state.selectedIndex }, session.id)),
5665
+ /* @__PURE__ */ jsx(PreviewPane, { session: selected })
5666
+ ] });
5667
+ }
5668
+
5669
+ // src/interfaces/cli/session/pick/run-picker.tsx
5670
+ import { jsx as jsx2 } from "react/jsx-runtime";
5671
+ var PICK_NON_TTY_MESSAGE = "session pick requires an interactive terminal. Use `spx session pickup --auto` or `spx session pickup <id>` in a non-interactive context.";
5672
+ async function runPicker(sessions) {
5673
+ let claimed = null;
5674
+ let unmount = () => {
5675
+ };
5676
+ const instance = render(
5677
+ /* @__PURE__ */ jsx2(
5678
+ SessionPicker,
5679
+ {
5680
+ sessions,
5681
+ onClaim: (session) => {
5682
+ claimed = session;
5683
+ unmount();
5684
+ },
5685
+ onCancel: () => {
5686
+ unmount();
5687
+ }
5688
+ }
5689
+ )
5690
+ );
5691
+ unmount = instance.unmount;
5692
+ await instance.waitUntilExit();
5693
+ return claimed;
5694
+ }
5695
+
4874
5696
  // src/interfaces/cli/session.ts
4875
5697
  async function readStdin() {
4876
5698
  if (process.stdin.isTTY) {
@@ -4909,6 +5731,29 @@ function registerSessionCommands(sessionCmd) {
4909
5731
  handleError(error);
4910
5732
  }
4911
5733
  });
5734
+ sessionCmd.command("pick").description("Interactively pick and claim a session (move from todo to doing)").option("--sessions-dir <path>", "Custom sessions directory").action(async (options) => {
5735
+ try {
5736
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
5737
+ console.error(PICK_NON_TTY_MESSAGE);
5738
+ process.exit(1);
5739
+ }
5740
+ const sessions = await loadPickCandidates({
5741
+ sessionsDir: options.sessionsDir,
5742
+ onWarning: writeWarning
5743
+ });
5744
+ const claimed = await runPicker(sessions);
5745
+ if (claimed !== null) {
5746
+ const output = await pickupCommand({
5747
+ sessionIds: [claimed.id],
5748
+ sessionsDir: options.sessionsDir,
5749
+ onWarning: writeWarning
5750
+ });
5751
+ console.log(output);
5752
+ }
5753
+ } catch (error) {
5754
+ handleError(error);
5755
+ }
5756
+ });
4912
5757
  sessionCmd.command("todo").description("List todo sessions").option("--json", "Output as JSON").option("--fields <fields>", "Comma-separated fields to emit as JSON (implies --json)").option("--sessions-dir <path>", "Custom sessions directory").action(async (options) => {
4913
5758
  try {
4914
5759
  const output = await listCommand({
@@ -5043,7 +5888,7 @@ var sessionDomain = {
5043
5888
 
5044
5889
  // src/lib/spec-tree/index.ts
5045
5890
  import { readdir as readdir5, readFile as readFile7 } from "fs/promises";
5046
- import { join as join13 } from "path";
5891
+ import { join as join14 } from "path";
5047
5892
  var SPEC_TREE_FIELD_KEY = {
5048
5893
  VERSION: "version",
5049
5894
  PRODUCT: "product",
@@ -5127,7 +5972,7 @@ function createFilesystemSpecTreeSource(options) {
5127
5972
  if (ref.path === void 0) {
5128
5973
  throw new Error("Filesystem source refs require a path");
5129
5974
  }
5130
- return readFile7(join13(options.productDir, ref.path), SPEC_TREE_TEXT_ENCODING);
5975
+ return readFile7(join14(options.productDir, ref.path), SPEC_TREE_TEXT_ENCODING);
5131
5976
  }
5132
5977
  };
5133
5978
  }
@@ -5409,7 +6254,7 @@ function compareOrderedEntries(left, right) {
5409
6254
  }
5410
6255
  async function* readFilesystemSourceEntries(productDir, registry, schemaVersions, includePath) {
5411
6256
  yield* walkFilesystemDirectory({
5412
- absolutePath: join13(productDir, SPEC_TREE_CONFIG.ROOT_DIRECTORY),
6257
+ absolutePath: join14(productDir, SPEC_TREE_CONFIG.ROOT_DIRECTORY),
5413
6258
  relativePath: SPEC_TREE_EMPTY_RELATIVE_PATH,
5414
6259
  registry,
5415
6260
  schemaVersions,
@@ -5438,7 +6283,7 @@ async function* walkFilesystemDirectory(context) {
5438
6283
  if (sourceEntry !== null) yield sourceEntry;
5439
6284
  if (entry.isDirectory() && shouldDescendIntoDirectory(sourceEntry)) {
5440
6285
  yield* walkFilesystemDirectory({
5441
- absolutePath: join13(context.absolutePath, entry.name),
6286
+ absolutePath: join14(context.absolutePath, entry.name),
5442
6287
  relativePath,
5443
6288
  registry: context.registry,
5444
6289
  schemaVersions: context.schemaVersions,
@@ -5584,14 +6429,14 @@ function formatNextNode(node) {
5584
6429
 
5585
6430
  // src/commands/testing/discovery.ts
5586
6431
  import { readdir as readdir6 } from "fs/promises";
5587
- import { join as join14, relative, sep } from "path";
6432
+ import { join as join15, relative, sep } from "path";
5588
6433
  var TESTS_DIRECTORY_NAME = SPEC_TREE_EVIDENCE_FILE.DIRECTORY_NAME;
5589
6434
  var SPEC_ROOT_DIRECTORY = SPEC_TREE_CONFIG.ROOT_DIRECTORY;
5590
6435
  var POSIX_SEPARATOR = "/";
5591
6436
  var ERROR_CODE_NOT_FOUND2 = "ENOENT";
5592
6437
  async function discoverTestFiles(productDir) {
5593
6438
  const found = [];
5594
- await collectTestFiles(join14(productDir, SPEC_ROOT_DIRECTORY), false, found);
6439
+ await collectTestFiles(join15(productDir, SPEC_ROOT_DIRECTORY), false, found);
5595
6440
  return found.map((absolute) => toPosixRelative(productDir, absolute)).sort(compareAscii);
5596
6441
  }
5597
6442
  async function collectTestFiles(directory, insideTestsDir, found) {
@@ -5603,7 +6448,7 @@ async function collectTestFiles(directory, insideTestsDir, found) {
5603
6448
  throw error;
5604
6449
  }
5605
6450
  for (const entry of entries) {
5606
- const childPath = join14(directory, entry.name);
6451
+ const childPath = join15(directory, entry.name);
5607
6452
  if (entry.isDirectory()) {
5608
6453
  await collectTestFiles(childPath, insideTestsDir || entry.name === TESTS_DIRECTORY_NAME, found);
5609
6454
  } else if (insideTestsDir && entry.isFile()) {
@@ -5685,11 +6530,11 @@ async function runTests(options, deps) {
5685
6530
  }
5686
6531
 
5687
6532
  // src/commands/testing/run-command.ts
5688
- import { join as join16 } from "path";
6533
+ import { join as join17 } from "path";
5689
6534
 
5690
6535
  // src/testing/run-state.ts
5691
6536
  import { createHash as createHash4 } from "crypto";
5692
- import { join as join15 } from "path";
6537
+ import { join as join16 } from "path";
5693
6538
  var TEST_RUN_STATE_STATUS = {
5694
6539
  PASSED: "passed",
5695
6540
  FAILED: "failed",
@@ -5776,12 +6621,12 @@ async function readTestingRuns(productDir, options = {}) {
5776
6621
  if (hasErrorCode(error, TESTING_RUN_STATE_ERROR_CODE.NOT_FOUND)) {
5777
6622
  return { ok: true, value: { terminalRuns: [], incompleteRuns: [] } };
5778
6623
  }
5779
- return { ok: false, error: toErrorMessage2(error) };
6624
+ return { ok: false, error: toErrorMessage3(error) };
5780
6625
  }
5781
6626
  const terminalRuns = [];
5782
6627
  const incompleteRuns = [];
5783
6628
  for (const entry of entries.filter(isTestRunFileEntry)) {
5784
- const runFilePath = join15(runsDir2, entry.name);
6629
+ const runFilePath = join16(runsDir2, entry.name);
5785
6630
  const stateResult = await readTestRunStatePath(runFilePath, fs7);
5786
6631
  if (stateResult.ok) {
5787
6632
  terminalRuns.push({ runFileName: entry.name, runFilePath, state: stateResult.value });
@@ -5858,7 +6703,7 @@ async function readTestRunStatePath(runFilePath, fs7) {
5858
6703
  return {
5859
6704
  ok: false,
5860
6705
  reason: hasErrorCode(error, TESTING_RUN_STATE_ERROR_CODE.NOT_FOUND) ? TESTING_RUN_STATE_INCOMPLETE_REASON.MISSING_STATE : TESTING_RUN_STATE_INCOMPLETE_REASON.IO_ERROR,
5861
- error: toErrorMessage2(error)
6706
+ error: toErrorMessage3(error)
5862
6707
  };
5863
6708
  }
5864
6709
  const latest = latestNonEmptyJsonlLine(content);
@@ -6010,7 +6855,7 @@ function isRecord4(value) {
6010
6855
  function sha256Hex(value) {
6011
6856
  return createHash4(SHA256_ALGORITHM2).update(value).digest(HEX_ENCODING3);
6012
6857
  }
6013
- function toErrorMessage2(error) {
6858
+ function toErrorMessage3(error) {
6014
6859
  return error instanceof Error ? error.message : String(error);
6015
6860
  }
6016
6861
 
@@ -6047,7 +6892,7 @@ function coveredTestPaths(dispatch) {
6047
6892
  async function readCoveredContents(productDir, paths, fs7) {
6048
6893
  const entries = [];
6049
6894
  for (const path6 of paths) {
6050
- entries.push({ path: path6, content: await fs7.readFile(join16(productDir, path6), TEXT_ENCODING) });
6895
+ entries.push({ path: path6, content: await fs7.readFile(join17(productDir, path6), TEXT_ENCODING) });
6051
6896
  }
6052
6897
  return entries;
6053
6898
  }
@@ -6058,7 +6903,7 @@ async function readProductInputEntries(productDir, paths, fs7) {
6058
6903
  entries.push({
6059
6904
  [PRODUCT_INPUT_FIELDS.PATH]: path6,
6060
6905
  [PRODUCT_INPUT_FIELDS.PRESENT]: true,
6061
- [PRODUCT_INPUT_FIELDS.CONTENT]: await fs7.readFile(join16(productDir, path6), TEXT_ENCODING)
6906
+ [PRODUCT_INPUT_FIELDS.CONTENT]: await fs7.readFile(join17(productDir, path6), TEXT_ENCODING)
6062
6907
  });
6063
6908
  } catch (error) {
6064
6909
  if (!hasErrorCode(error, TESTING_RUN_STATE_ERROR_CODE.NOT_FOUND)) throw error;
@@ -6241,11 +7086,11 @@ function serializeNodeStatus(state) {
6241
7086
  }
6242
7087
 
6243
7088
  // src/lib/node-status/provider.ts
6244
- import { join as join18 } from "path";
7089
+ import { join as join19 } from "path";
6245
7090
 
6246
7091
  // src/lib/node-status/read.ts
6247
7092
  import { readFileSync as readFileSync2 } from "fs";
6248
- import { join as join17 } from "path";
7093
+ import { join as join18 } from "path";
6249
7094
  var NODE_STATUS_FILENAME = "spx.status.json";
6250
7095
  var NODE_STATUS_VALUES = new Set(Object.values(SPEC_TREE_NODE_STATE));
6251
7096
  function isNodeError2(error) {
@@ -6255,7 +7100,7 @@ function isSpecTreeNodeState(value) {
6255
7100
  return typeof value === "string" && NODE_STATUS_VALUES.has(value);
6256
7101
  }
6257
7102
  function readNodeStatus(nodeDir) {
6258
- const filePath = join17(nodeDir, NODE_STATUS_FILENAME);
7103
+ const filePath = join18(nodeDir, NODE_STATUS_FILENAME);
6259
7104
  let content;
6260
7105
  try {
6261
7106
  content = readFileSync2(filePath, "utf8");
@@ -6277,7 +7122,7 @@ function readNodeStatus(nodeDir) {
6277
7122
 
6278
7123
  // src/lib/node-status/provider.ts
6279
7124
  function nodeDirectory(productDir, node) {
6280
- return join18(productDir, SPEC_TREE_CONFIG.ROOT_DIRECTORY, node.id);
7125
+ return join19(productDir, SPEC_TREE_CONFIG.ROOT_DIRECTORY, node.id);
6281
7126
  }
6282
7127
  function createNodeStatusProvider(productDir) {
6283
7128
  return {
@@ -6289,7 +7134,7 @@ function createNodeStatusProvider(productDir) {
6289
7134
 
6290
7135
  // src/lib/node-status/update.ts
6291
7136
  import { mkdir as mkdir4, writeFile as writeFile2 } from "fs/promises";
6292
- import { dirname as dirname4, join as join19 } from "path";
7137
+ import { dirname as dirname5, join as join20 } from "path";
6293
7138
  var NODE_STATUS_TEXT_ENCODING = "utf8";
6294
7139
  async function updateNodeStatus(options) {
6295
7140
  const { productDir, resolveOutcome } = options;
@@ -6327,8 +7172,8 @@ function isNodeExcluded(ignoreReader, node) {
6327
7172
  return ignoreReader.isUnderIgnoreSource(reference);
6328
7173
  }
6329
7174
  async function writeNodeStatus(productDir, nodeId, state) {
6330
- const filePath = join19(productDir, SPEC_TREE_CONFIG.ROOT_DIRECTORY, nodeId, NODE_STATUS_FILENAME);
6331
- await mkdir4(dirname4(filePath), { recursive: true });
7175
+ const filePath = join20(productDir, SPEC_TREE_CONFIG.ROOT_DIRECTORY, nodeId, NODE_STATUS_FILENAME);
7176
+ await mkdir4(dirname5(filePath), { recursive: true });
6332
7177
  await writeFile2(filePath, serializeNodeStatus(state), NODE_STATUS_TEXT_ENCODING);
6333
7178
  }
6334
7179
 
@@ -6442,7 +7287,7 @@ function formatNodeLabel(node) {
6442
7287
  }
6443
7288
 
6444
7289
  // src/testing/languages/python.ts
6445
- import { basename as basename2, dirname as dirname5, join as join20 } from "path/posix";
7290
+ import { basename as basename3, dirname as dirname6, join as join21 } from "path/posix";
6446
7291
 
6447
7292
  // src/validation/discovery/language-finder.ts
6448
7293
  import fs5 from "fs";
@@ -6507,16 +7352,16 @@ var PYTHON_TEST_FILE_PREFIX = "test_";
6507
7352
  var PYTHON_TEST_FILE_EXTENSION = ".py";
6508
7353
  var PYTHON_TEST_FILE_PATTERNS = [`${PYTHON_TEST_FILE_PREFIX}*${PYTHON_TEST_FILE_EXTENSION}`];
6509
7354
  function matchesTestFile(filePath) {
6510
- return basename2(filePath).startsWith(PYTHON_TEST_FILE_PREFIX) && filePath.endsWith(PYTHON_TEST_FILE_EXTENSION);
7355
+ return basename3(filePath).startsWith(PYTHON_TEST_FILE_PREFIX) && filePath.endsWith(PYTHON_TEST_FILE_EXTENSION);
6511
7356
  }
6512
7357
  function coveredProductInputPaths(coveredTestPaths2) {
6513
7358
  const paths = /* @__PURE__ */ new Set();
6514
7359
  for (const testPath of coveredTestPaths2) {
6515
7360
  if (!matchesTestFile(testPath)) continue;
6516
- let directory = dirname5(testPath);
7361
+ let directory = dirname6(testPath);
6517
7362
  while (directory !== "." && directory.length > 0) {
6518
- paths.add(join20(directory, PYTHON_PRODUCT_INPUT_PATH.CONFTEST));
6519
- const parent = dirname5(directory);
7363
+ paths.add(join21(directory, PYTHON_PRODUCT_INPUT_PATH.CONFTEST));
7364
+ const parent = dirname6(directory);
6520
7365
  if (parent === directory) break;
6521
7366
  directory = parent;
6522
7367
  }
@@ -6997,7 +7842,7 @@ function applyValidationPathFilterToScope(scopeConfig, filter) {
6997
7842
 
6998
7843
  // src/validation/steps/markdown.ts
6999
7844
  import { existsSync, statSync } from "fs";
7000
- import { basename as basename3, dirname as dirname6, join as join21, relative as pathRelative } from "path";
7845
+ import { basename as basename4, dirname as dirname7, join as join22, relative as pathRelative } from "path";
7001
7846
  import { main as markdownlintMain } from "markdownlint-cli2";
7002
7847
  import relativeLinksRule from "markdownlint-rule-relative-links";
7003
7848
  var MARKDOWN_DEFAULT_DIRECTORY_NAMES = ["spx", "docs"];
@@ -7036,7 +7881,7 @@ function buildMarkdownlintConfig(directoryName) {
7036
7881
  };
7037
7882
  }
7038
7883
  function getDefaultDirectories(projectRoot) {
7039
- return MARKDOWN_DEFAULT_DIRECTORY_NAMES.map((name) => join21(projectRoot, name)).filter((dir) => existsSync(dir));
7884
+ return MARKDOWN_DEFAULT_DIRECTORY_NAMES.map((name) => join22(projectRoot, name)).filter((dir) => existsSync(dir));
7040
7885
  }
7041
7886
  function resolveMarkdownValidationTarget(path6, deps = defaultMarkdownValidationTargetDeps) {
7042
7887
  if (isExistingDirectory(path6, deps)) {
@@ -7095,7 +7940,7 @@ async function validateMarkdown(options) {
7095
7940
  async function validateTarget(target, config, projectRoot, ignoreGlobs = []) {
7096
7941
  const errors = [];
7097
7942
  const directory = targetDirectory(target);
7098
- const argv = target.kind === MARKDOWN_VALIDATION_TARGET_KIND.FILE ? [basename3(target.path)] : [MARKDOWN_DIRECTORY_GLOB];
7943
+ const argv = target.kind === MARKDOWN_VALIDATION_TARGET_KIND.FILE ? [basename4(target.path)] : [MARKDOWN_DIRECTORY_GLOB];
7099
7944
  const { customRules, ...markdownlintConfig } = config;
7100
7945
  const optionsOverride = {
7101
7946
  config: {
@@ -7119,7 +7964,7 @@ async function validateTarget(target, config, projectRoot, ignoreGlobs = []) {
7119
7964
  if (parsed) {
7120
7965
  errors.push({
7121
7966
  ...parsed,
7122
- file: join21(directory, parsed.file)
7967
+ file: join22(directory, parsed.file)
7123
7968
  });
7124
7969
  }
7125
7970
  }
@@ -7127,7 +7972,7 @@ async function validateTarget(target, config, projectRoot, ignoreGlobs = []) {
7127
7972
  return errors;
7128
7973
  }
7129
7974
  function targetDirectory(target) {
7130
- return target.kind === MARKDOWN_VALIDATION_TARGET_KIND.FILE ? dirname6(target.path) : target.path;
7975
+ return target.kind === MARKDOWN_VALIDATION_TARGET_KIND.FILE ? dirname7(target.path) : target.path;
7131
7976
  }
7132
7977
  function hasMarkdownExtension(path6) {
7133
7978
  const lastDot = path6.lastIndexOf(".");
@@ -7155,7 +8000,7 @@ function markdownlintConfigDirectoryName(directory, projectRoot) {
7155
8000
  return rootSegment;
7156
8001
  }
7157
8002
  }
7158
- return basename3(directory);
8003
+ return basename4(directory);
7159
8004
  }
7160
8005
 
7161
8006
  // src/commands/validation/messages.ts
@@ -7169,6 +8014,7 @@ var VALIDATION_STAGE_DISPLAY_NAMES = {
7169
8014
  };
7170
8015
  var VALIDATION_SKIP_LABELS = {
7171
8016
  VERB: "Skipping",
8017
+ CIRCULAR_REASON: "skip-circular",
7172
8018
  LITERAL_REASON: "skip-literal",
7173
8019
  TYPESCRIPT_ABSENT_REASON: "TypeScript not detected in project",
7174
8020
  VALIDATION_PATHS_NO_TARGETS_REASON: "validation paths matched no files",
@@ -7190,6 +8036,11 @@ var VALIDATION_COMMAND_OUTPUT = {
7190
8036
  MARKDOWN_NO_ISSUES: `${VALIDATION_STAGE_DISPLAY_NAMES.MARKDOWN}: No issues found`,
7191
8037
  MARKDOWN_ERROR_SUMMARY_SUFFIX: "error(s) found"
7192
8038
  };
8039
+ var CIRCULAR_SKIP_OUTPUT = `${VALIDATION_STAGE_DISPLAY_NAMES.CIRCULAR}: skipped (--${VALIDATION_SKIP_LABELS.CIRCULAR_REASON})`;
8040
+ var CIRCULAR_SKIP_JSON_OUTPUT = JSON.stringify({
8041
+ skipped: true,
8042
+ reason: VALIDATION_SKIP_LABELS.CIRCULAR_REASON
8043
+ });
7193
8044
  var LITERAL_SKIP_OUTPUT = `${VALIDATION_STAGE_DISPLAY_NAMES.LITERAL}: skipped (--${VALIDATION_SKIP_LABELS.LITERAL_REASON})`;
7194
8045
  var LITERAL_SKIP_JSON_OUTPUT = JSON.stringify({
7195
8046
  skipped: true,
@@ -8141,7 +8992,7 @@ var ParseErrorCode;
8141
8992
 
8142
8993
  // src/validation/config/scope.ts
8143
8994
  import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync3 } from "fs";
8144
- import { isAbsolute as isAbsolute3, join as join22 } from "path";
8995
+ import { isAbsolute as isAbsolute3, join as join23 } from "path";
8145
8996
  var TSCONFIG_FILES = {
8146
8997
  full: "tsconfig.json",
8147
8998
  production: "tsconfig.production.json"
@@ -8155,7 +9006,7 @@ var defaultScopeDeps = {
8155
9006
  readdirSync
8156
9007
  };
8157
9008
  function resolveProjectPath(projectRoot, path6) {
8158
- return isAbsolute3(path6) ? path6 : join22(projectRoot, path6);
9009
+ return isAbsolute3(path6) ? path6 : join23(projectRoot, path6);
8159
9010
  }
8160
9011
  function parseTypeScriptConfig(configPath, deps = defaultScopeDeps) {
8161
9012
  try {
@@ -8199,7 +9050,7 @@ function hasTypeScriptFilesRecursive(dirPath, maxDepth = 2, deps = defaultScopeD
8199
9050
  if (hasDirectTsFiles) return true;
8200
9051
  const subdirs = items.filter((item) => item.isDirectory() && !item.name.startsWith("."));
8201
9052
  for (const subdir of subdirs.slice(0, 5)) {
8202
- if (hasTypeScriptFilesRecursive(join22(dirPath, subdir.name), maxDepth - 1, deps)) {
9053
+ if (hasTypeScriptFilesRecursive(join23(dirPath, subdir.name), maxDepth - 1, deps)) {
8203
9054
  return true;
8204
9055
  }
8205
9056
  }
@@ -8222,7 +9073,7 @@ function getTopLevelDirectoriesWithTypeScript(config, projectRoot, deps = defaul
8222
9073
  });
8223
9074
  if (!isExcluded) {
8224
9075
  try {
8225
- const hasTypeScriptFiles = hasTypeScriptFilesRecursive(join22(projectRoot, dir), 2, deps);
9076
+ const hasTypeScriptFiles = hasTypeScriptFilesRecursive(join23(projectRoot, dir), 2, deps);
8226
9077
  if (hasTypeScriptFiles) {
8227
9078
  directories.add(dir);
8228
9079
  }
@@ -8253,13 +9104,13 @@ function getLiteralTopLevelPatternDirectory(pattern) {
8253
9104
  function filterActiveIncludePatterns(patterns, excludePatterns, projectRoot, deps) {
8254
9105
  return patterns.filter((pattern) => {
8255
9106
  const topLevelDir = getLiteralTopLevelPatternDirectory(pattern);
8256
- return topLevelDir === null || deps.existsSync(join22(projectRoot, topLevelDir));
9107
+ return topLevelDir === null || deps.existsSync(join23(projectRoot, topLevelDir));
8257
9108
  }).filter((pattern) => pathPassesValidationFilter(pattern, { exclude: excludePatterns }));
8258
9109
  }
8259
9110
  function getValidationDirectories(scope, projectRoot, deps = defaultScopeDeps) {
8260
9111
  const config = resolveTypeScriptConfig(scope, projectRoot, deps);
8261
9112
  const configDirectories = getTopLevelDirectoriesWithTypeScript(config, projectRoot, deps);
8262
- const existingDirectories = configDirectories.filter((dir) => deps.existsSync(join22(projectRoot, dir)));
9113
+ const existingDirectories = configDirectories.filter((dir) => deps.existsSync(join23(projectRoot, dir)));
8263
9114
  return existingDirectories;
8264
9115
  }
8265
9116
  function getTypeScriptScope(scope, projectRoot, deps = defaultScopeDeps) {
@@ -8273,10 +9124,10 @@ function getTypeScriptScope(scope, projectRoot, deps = defaultScopeDeps) {
8273
9124
  }
8274
9125
 
8275
9126
  // src/validation/discovery/tool-finder.ts
8276
- import { execSync as execSync2 } from "child_process";
8277
9127
  import fs6 from "fs";
8278
9128
  import { createRequire } from "module";
8279
9129
  import path5 from "path";
9130
+ import { fileURLToPath } from "url";
8280
9131
 
8281
9132
  // src/validation/discovery/constants.ts
8282
9133
  var TOOL_DISCOVERY = {
@@ -8310,6 +9161,50 @@ var TOOL_DISCOVERY = {
8310
9161
 
8311
9162
  // src/validation/discovery/tool-finder.ts
8312
9163
  var require2 = createRequire(import.meta.url);
9164
+ var FILE_URL_PROTOCOL = new URL(import.meta.url).protocol;
9165
+ var PACKAGE_MANIFEST_FILENAME = "package.json";
9166
+ var PATH_ENVIRONMENT_VARIABLE = "PATH";
9167
+ var WINDOWS_EXECUTABLE_EXTENSIONS_VARIABLE = "PATHEXT";
9168
+ var WINDOWS_DEFAULT_EXECUTABLE_EXTENSIONS = [".COM", ".EXE", ".BAT", ".CMD"];
9169
+ function executableExtensions() {
9170
+ if (process.platform !== "win32") {
9171
+ return [""];
9172
+ }
9173
+ const pathExtensions = process.env[WINDOWS_EXECUTABLE_EXTENSIONS_VARIABLE]?.split(path5.delimiter).filter(Boolean);
9174
+ return pathExtensions && pathExtensions.length > 0 ? pathExtensions : WINDOWS_DEFAULT_EXECUTABLE_EXTENSIONS;
9175
+ }
9176
+ function executableCandidateNames(tool) {
9177
+ if (process.platform !== "win32" || path5.extname(tool) !== "") {
9178
+ return [tool];
9179
+ }
9180
+ return executableExtensions().map((extension) => `${tool}${extension}`);
9181
+ }
9182
+ function isExecutableFile(filePath) {
9183
+ try {
9184
+ fs6.accessSync(filePath, fs6.constants.X_OK);
9185
+ return true;
9186
+ } catch {
9187
+ return false;
9188
+ }
9189
+ }
9190
+ function findExecutableOnPath(tool) {
9191
+ const pathValue = process.env[PATH_ENVIRONMENT_VARIABLE];
9192
+ if (!pathValue) {
9193
+ return null;
9194
+ }
9195
+ for (const directory of pathValue.split(path5.delimiter)) {
9196
+ if (!directory) {
9197
+ continue;
9198
+ }
9199
+ for (const candidateName of executableCandidateNames(tool)) {
9200
+ const candidatePath = path5.join(directory, candidateName);
9201
+ if (isExecutableFile(candidatePath)) {
9202
+ return candidatePath;
9203
+ }
9204
+ }
9205
+ }
9206
+ return null;
9207
+ }
8313
9208
  var defaultToolDiscoveryDeps = {
8314
9209
  resolveModule: (modulePath) => {
8315
9210
  try {
@@ -8318,28 +9213,50 @@ var defaultToolDiscoveryDeps = {
8318
9213
  return null;
8319
9214
  }
8320
9215
  },
8321
- existsSync: fs6.existsSync,
8322
- whichSync: (tool) => {
9216
+ resolveImport: (modulePath) => {
8323
9217
  try {
8324
- const result = execSync2(`which ${tool}`, {
8325
- encoding: "utf-8",
8326
- stdio: ["pipe", "pipe", "pipe"]
8327
- });
8328
- return result.trim() || null;
9218
+ return import.meta.resolve(modulePath);
8329
9219
  } catch {
8330
9220
  return null;
8331
9221
  }
8332
- }
9222
+ },
9223
+ existsSync: fs6.existsSync,
9224
+ whichSync: findExecutableOnPath
8333
9225
  };
9226
+ function resolvedModulePath(resolvedPath) {
9227
+ try {
9228
+ const resolvedUrl = new URL(resolvedPath);
9229
+ return resolvedUrl.protocol === FILE_URL_PROTOCOL ? fileURLToPath(resolvedUrl) : resolvedPath;
9230
+ } catch {
9231
+ return resolvedPath;
9232
+ }
9233
+ }
9234
+ function nearestPackageRoot(filePath, existsSync7) {
9235
+ let currentDirectory = path5.dirname(filePath);
9236
+ while (true) {
9237
+ if (existsSync7(path5.join(currentDirectory, PACKAGE_MANIFEST_FILENAME))) {
9238
+ return currentDirectory;
9239
+ }
9240
+ const parentDirectory = path5.dirname(currentDirectory);
9241
+ if (parentDirectory === currentDirectory) {
9242
+ return null;
9243
+ }
9244
+ currentDirectory = parentDirectory;
9245
+ }
9246
+ }
9247
+ function bundledToolPath(resolvedPath, existsSync7) {
9248
+ const bundledFilePath = resolvedModulePath(resolvedPath);
9249
+ return nearestPackageRoot(bundledFilePath, existsSync7) ?? path5.dirname(bundledFilePath);
9250
+ }
8334
9251
  async function discoverTool(tool, options = {}) {
8335
9252
  const { projectRoot = process.cwd(), deps = defaultToolDiscoveryDeps } = options;
8336
- const bundledPath = deps.resolveModule(`${tool}/package.json`);
9253
+ const bundledPath = deps.resolveModule(`${tool}/package.json`) ?? deps.resolveImport?.(tool);
8337
9254
  if (bundledPath) {
8338
9255
  return {
8339
9256
  found: true,
8340
9257
  location: {
8341
9258
  tool,
8342
- path: path5.dirname(bundledPath),
9259
+ path: bundledToolPath(bundledPath, deps.existsSync),
8343
9260
  source: TOOL_DISCOVERY.SOURCES.BUNDLED
8344
9261
  }
8345
9262
  };
@@ -8382,33 +9299,180 @@ function formatSkipMessage(stepName, result) {
8382
9299
  }
8383
9300
 
8384
9301
  // src/validation/steps/circular.ts
8385
- import madge from "madge";
8386
- import { join as join23 } from "path";
9302
+ import {
9303
+ cruise as dependencyCruiser
9304
+ } from "dependency-cruiser";
9305
+ import extractTypeScriptConfig from "dependency-cruiser/config-utl/extract-ts-config";
9306
+ import { join as join24 } from "path";
9307
+ var DEPENDENCY_CRUISER_MODULE_SYSTEMS = ["es6", "cjs"];
9308
+ var DEPENDENCY_CRUISER_TYPESCRIPT_SOURCE_GLOB_SUFFIXES = ["**/*.ts", "**/*.tsx"];
9309
+ var DEPENDENCY_CRUISER_TYPESCRIPT_SOURCE_PATTERN = String.raw`\.tsx?$`;
9310
+ var DEPENDENCY_CRUISER_TYPESCRIPT_RESOLVE_EXTENSIONS = [".ts", ".tsx", ".d.ts"];
9311
+ var TSCONFIG_EXCLUDE_SUFFIX_PATTERN = /\/\*\*?\/\*$/u;
9312
+ var REGEX_SPECIAL_CHARACTER_PATTERN = /[.*+?^${}()|[\]\\]/gu;
9313
+ var REGEX_ESCAPE_REPLACEMENT = String.raw`\$&`;
9314
+ var CYCLE_KEY_SEPARATOR = "\0";
9315
+ var DEPENDENCY_CRUISER_PACKAGE_EXCLUDE_PATTERN = "(^|/)node_modules(/|$)";
9316
+ var DEPENDENCY_CRUISER_DEPENDENCY_TYPES = {
9317
+ AMD_DEFINE: "amd-define",
9318
+ AMD_EXOTIC_REQUIRE: "amd-exotic-require",
9319
+ AMD_REQUIRE: "amd-require",
9320
+ DYNAMIC_IMPORT: "dynamic-import",
9321
+ EXPORT: "export",
9322
+ EXOTIC_REQUIRE: "exotic-require",
9323
+ IMPORT: "import",
9324
+ IMPORT_EQUALS: "import-equals",
9325
+ LOCAL: "local",
9326
+ PRE_COMPILATION_ONLY: "pre-compilation-only",
9327
+ REQUIRE: "require",
9328
+ TYPE_IMPORT: "type-import",
9329
+ // dependency-cruiser 16.10.4 emits this for TypeScript `import type` edges.
9330
+ TYPE_ONLY: "type-only"
9331
+ };
9332
+ var DEPENDENCY_CRUISER_TS_PRE_COMPILATION_DEPS = "specify";
9333
+ var ERASURE_ONLY_DEPENDENCY_TYPES = /* @__PURE__ */ new Set([
9334
+ DEPENDENCY_CRUISER_DEPENDENCY_TYPES.PRE_COMPILATION_ONLY,
9335
+ DEPENDENCY_CRUISER_DEPENDENCY_TYPES.TYPE_IMPORT,
9336
+ DEPENDENCY_CRUISER_DEPENDENCY_TYPES.TYPE_ONLY
9337
+ ]);
9338
+ var RUNTIME_DEPENDENCY_TYPES = /* @__PURE__ */ new Set([
9339
+ DEPENDENCY_CRUISER_DEPENDENCY_TYPES.AMD_DEFINE,
9340
+ DEPENDENCY_CRUISER_DEPENDENCY_TYPES.AMD_EXOTIC_REQUIRE,
9341
+ DEPENDENCY_CRUISER_DEPENDENCY_TYPES.AMD_REQUIRE,
9342
+ DEPENDENCY_CRUISER_DEPENDENCY_TYPES.DYNAMIC_IMPORT,
9343
+ DEPENDENCY_CRUISER_DEPENDENCY_TYPES.EXPORT,
9344
+ DEPENDENCY_CRUISER_DEPENDENCY_TYPES.EXOTIC_REQUIRE,
9345
+ DEPENDENCY_CRUISER_DEPENDENCY_TYPES.IMPORT,
9346
+ DEPENDENCY_CRUISER_DEPENDENCY_TYPES.IMPORT_EQUALS,
9347
+ DEPENDENCY_CRUISER_DEPENDENCY_TYPES.REQUIRE
9348
+ ]);
8387
9349
  var CIRCULAR_DEPS_KEYS = {
8388
- MADGE: "madge"
9350
+ DEPENDENCY_CRUISER: "dependencyCruiser",
9351
+ EXTRACT_TYPESCRIPT_CONFIG: "extractTypeScriptConfig"
8389
9352
  };
8390
9353
  var defaultCircularDeps = {
8391
- [CIRCULAR_DEPS_KEYS.MADGE]: madge
9354
+ [CIRCULAR_DEPS_KEYS.DEPENDENCY_CRUISER]: dependencyCruiser,
9355
+ [CIRCULAR_DEPS_KEYS.EXTRACT_TYPESCRIPT_CONFIG]: extractTypeScriptConfig
8392
9356
  };
9357
+ function toDependencyCruiserExcludePatterns(patterns) {
9358
+ return patterns.map((pattern) => {
9359
+ const cleanPattern = pattern.replace(TSCONFIG_EXCLUDE_SUFFIX_PATTERN, "");
9360
+ return cleanPattern.replace(REGEX_SPECIAL_CHARACTER_PATTERN, REGEX_ESCAPE_REPLACEMENT);
9361
+ });
9362
+ }
9363
+ function buildDependencyCruiserOptions(typescriptScope, projectRoot, tsConfigFile) {
9364
+ const excludePatterns = [
9365
+ DEPENDENCY_CRUISER_PACKAGE_EXCLUDE_PATTERN,
9366
+ ...toDependencyCruiserExcludePatterns(typescriptScope.excludePatterns)
9367
+ ];
9368
+ return {
9369
+ baseDir: projectRoot,
9370
+ enhancedResolveOptions: { extensions: [...DEPENDENCY_CRUISER_TYPESCRIPT_RESOLVE_EXTENSIONS] },
9371
+ exclude: { path: excludePatterns },
9372
+ includeOnly: { path: DEPENDENCY_CRUISER_TYPESCRIPT_SOURCE_PATTERN },
9373
+ moduleSystems: [...DEPENDENCY_CRUISER_MODULE_SYSTEMS],
9374
+ tsConfig: { fileName: tsConfigFile },
9375
+ tsPreCompilationDeps: DEPENDENCY_CRUISER_TS_PRE_COMPILATION_DEPS
9376
+ };
9377
+ }
9378
+ function toDependencyCruiserSourcePatterns(directories) {
9379
+ return directories.flatMap(
9380
+ (directory) => DEPENDENCY_CRUISER_TYPESCRIPT_SOURCE_GLOB_SUFFIXES.map((suffix) => `${directory}/${suffix}`)
9381
+ );
9382
+ }
9383
+ function isCruiseResult(output) {
9384
+ return typeof output === "object" && output !== null && "modules" in output && "summary" in output;
9385
+ }
9386
+ function closeCycle(cycle) {
9387
+ const first = cycle[0];
9388
+ const last = cycle.at(-1);
9389
+ if (first === void 0) {
9390
+ return [];
9391
+ }
9392
+ return last === first ? [...cycle] : [...cycle, first];
9393
+ }
9394
+ function dependencyTypesSurviveRuntime(dependencyTypes) {
9395
+ if (dependencyTypes.includes(DEPENDENCY_CRUISER_DEPENDENCY_TYPES.PRE_COMPILATION_ONLY)) {
9396
+ return false;
9397
+ }
9398
+ if (dependencyTypes.some((dependencyType) => RUNTIME_DEPENDENCY_TYPES.has(dependencyType))) {
9399
+ return true;
9400
+ }
9401
+ return dependencyTypes.every((dependencyType) => !ERASURE_ONLY_DEPENDENCY_TYPES.has(dependencyType));
9402
+ }
9403
+ function dependencySurvivesRuntime(dependency) {
9404
+ return !dependency.typeOnly && !dependency.preCompilationOnly;
9405
+ }
9406
+ function dependencyCycleSurvivesRuntime(dependency) {
9407
+ return dependencySurvivesRuntime(dependency) && (dependency.cycle?.every((cycleDependency) => dependencyTypesSurviveRuntime(cycleDependency.dependencyTypes)) ?? true);
9408
+ }
9409
+ function dependencyCycleForModule(moduleSource, dependency) {
9410
+ if (!dependency.circular || !dependencyCycleSurvivesRuntime(dependency)) {
9411
+ return null;
9412
+ }
9413
+ const cycleTail = dependency.cycle?.map((cycleDependency) => cycleDependency.name) ?? [dependency.resolved];
9414
+ return closeCycle([moduleSource, ...cycleTail]);
9415
+ }
9416
+ function openCycle(cycle) {
9417
+ if (cycle.length > 1 && cycle[0] === cycle.at(-1)) {
9418
+ return cycle.slice(0, -1);
9419
+ }
9420
+ return [...cycle];
9421
+ }
9422
+ function cycleRotations(cycle) {
9423
+ return cycle.map(
9424
+ (_, index) => [
9425
+ ...cycle.slice(index),
9426
+ ...cycle.slice(0, index)
9427
+ ].join(CYCLE_KEY_SEPARATOR)
9428
+ );
9429
+ }
9430
+ function canonicalCycleKey(cycle) {
9431
+ const opened = openCycle(cycle);
9432
+ const reversed = [...opened].reverse();
9433
+ const keys = [...cycleRotations(opened), ...cycleRotations(reversed)].sort(
9434
+ (left, right) => left.localeCompare(right)
9435
+ );
9436
+ return keys[0] ?? "";
9437
+ }
9438
+ function uniqueCycles(cycles) {
9439
+ const seen = /* @__PURE__ */ new Set();
9440
+ const result = [];
9441
+ for (const cycle of cycles) {
9442
+ const key = canonicalCycleKey(cycle);
9443
+ if (!seen.has(key)) {
9444
+ seen.add(key);
9445
+ result.push(cycle);
9446
+ }
9447
+ }
9448
+ return result;
9449
+ }
9450
+ function circularDependencyCycles(result) {
9451
+ const cycles = result.modules.flatMap(
9452
+ (module) => module.dependencies.flatMap((dependency) => {
9453
+ const cycle = dependencyCycleForModule(module.source, dependency);
9454
+ return cycle === null ? [] : [cycle];
9455
+ })
9456
+ );
9457
+ return uniqueCycles(cycles);
9458
+ }
8393
9459
  async function validateCircularDependencies(scope, typescriptScope, projectRoot, deps = defaultCircularDeps) {
8394
9460
  try {
8395
- const analyzeDirectories = typescriptScope.directories.map((directory) => join23(projectRoot, directory));
8396
- if (analyzeDirectories.length === 0) {
9461
+ const analyzeSourcePatterns = toDependencyCruiserSourcePatterns(typescriptScope.directories);
9462
+ if (analyzeSourcePatterns.length === 0) {
8397
9463
  return { success: true };
8398
9464
  }
8399
- const tsConfigFile = join23(projectRoot, TSCONFIG_FILES[scope]);
8400
- const excludeRegExps = typescriptScope.excludePatterns.map((pattern) => {
8401
- const cleanPattern = pattern.replace(/\/\*\*?\/\*$/, "");
8402
- const escaped = cleanPattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8403
- return new RegExp(escaped);
8404
- });
8405
- const result = await deps.madge(analyzeDirectories, {
8406
- baseDir: projectRoot,
8407
- fileExtensions: ["ts", "tsx"],
8408
- tsConfig: tsConfigFile,
8409
- excludeRegExp: excludeRegExps
8410
- });
8411
- const circular = result.circular();
9465
+ const tsConfigFile = join24(projectRoot, TSCONFIG_FILES[scope]);
9466
+ const result = await deps.dependencyCruiser(
9467
+ analyzeSourcePatterns,
9468
+ buildDependencyCruiserOptions(typescriptScope, projectRoot, tsConfigFile),
9469
+ void 0,
9470
+ { tsConfig: deps.extractTypeScriptConfig(tsConfigFile) }
9471
+ );
9472
+ if (!isCruiseResult(result.output)) {
9473
+ return { success: false, error: "dependency-cruiser returned non-structured output" };
9474
+ }
9475
+ const circular = circularDependencyCycles(result.output);
8412
9476
  if (circular.length === 0) {
8413
9477
  return { success: true };
8414
9478
  } else {
@@ -8441,7 +9505,7 @@ async function circularCommand(options) {
8441
9505
  durationMs: Date.now() - startTime
8442
9506
  };
8443
9507
  }
8444
- const toolResult = await discoverTool("madge", { projectRoot: cwd });
9508
+ const toolResult = await discoverTool("dependency-cruiser", { projectRoot: cwd });
8445
9509
  if (!toolResult.found) {
8446
9510
  const skipMessage = formatSkipMessage("circular dependency check", toolResult);
8447
9511
  return { exitCode: 0, output: skipMessage, durationMs: Date.now() - startTime };
@@ -8479,7 +9543,7 @@ ${cycles}`;
8479
9543
  import { existsSync as existsSync3 } from "fs";
8480
9544
  import { mkdtemp, rm, writeFile as writeFile3 } from "fs/promises";
8481
9545
  import { tmpdir } from "os";
8482
- import { isAbsolute as isAbsolute4, join as join24 } from "path";
9546
+ import { isAbsolute as isAbsolute4, join as join25 } from "path";
8483
9547
  var defaultKnipProcessRunner = lifecycleProcessRunner;
8484
9548
  var KNIP_COMMAND_TOKENS = {
8485
9549
  COMMAND: "knip",
@@ -8507,7 +9571,7 @@ async function validateKnip2(context, runner = defaultKnipProcessRunner, deps =
8507
9571
  }
8508
9572
  async function runKnipSubprocess(projectRoot, typescriptScope, runner, deps) {
8509
9573
  const scopedTsconfig = typescriptScope.filteredByValidationPaths ? await createScopedKnipTsconfig(projectRoot, typescriptScope, deps) : void 0;
8510
- const localBin = join24(projectRoot, "node_modules", ".bin", "knip");
9574
+ const localBin = join25(projectRoot, "node_modules", ".bin", "knip");
8511
9575
  const binary = deps.existsSync(localBin) ? localBin : "npx";
8512
9576
  const baseArgs = scopedTsconfig === void 0 ? [] : [
8513
9577
  KNIP_COMMAND_TOKENS.USE_TSCONFIG_FILES_FLAG,
@@ -8562,12 +9626,12 @@ async function runKnipSubprocess(projectRoot, typescriptScope, runner, deps) {
8562
9626
  });
8563
9627
  }
8564
9628
  async function createScopedKnipTsconfig(projectRoot, typescriptScope, deps) {
8565
- const tempDir = await deps.mkdtemp(join24(tmpdir(), "validate-knip-"));
8566
- const configPath = join24(tempDir, TSCONFIG_FILES.full);
8567
- const toProjectPathPattern = (pattern) => isAbsolute4(pattern) ? pattern : join24(projectRoot, pattern);
9629
+ const tempDir = await deps.mkdtemp(join25(tmpdir(), "validate-knip-"));
9630
+ const configPath = join25(tempDir, TSCONFIG_FILES.full);
9631
+ const toProjectPathPattern = (pattern) => isAbsolute4(pattern) ? pattern : join25(projectRoot, pattern);
8568
9632
  const project = typescriptScope.filePatterns.length > 0 ? typescriptScope.filePatterns : typescriptScope.directories.map((directory) => `${directory}/**/*.{js,ts,tsx}`);
8569
9633
  const config = {
8570
- extends: join24(projectRoot, TSCONFIG_FILES.full),
9634
+ extends: join25(projectRoot, TSCONFIG_FILES.full),
8571
9635
  include: project.map(toProjectPathPattern),
8572
9636
  exclude: typescriptScope.excludePatterns.map(toProjectPathPattern)
8573
9637
  };
@@ -8617,12 +9681,12 @@ async function knipCommand(options) {
8617
9681
 
8618
9682
  // src/validation/steps/eslint.ts
8619
9683
  import { existsSync as existsSync5 } from "fs";
8620
- import { join as join26 } from "path";
9684
+ import { join as join27 } from "path";
8621
9685
 
8622
9686
  // src/validation/lint-policy.ts
8623
9687
  import { execFileSync } from "child_process";
8624
9688
  import { existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync4, statSync as statSync2 } from "fs";
8625
- import { join as join25 } from "path";
9689
+ import { join as join26 } from "path";
8626
9690
 
8627
9691
  // src/validation/lint-policy-constants.ts
8628
9692
  var LINT_POLICY_MANIFESTS = {
@@ -8670,17 +9734,17 @@ function isSpecTreeNodePath(entry) {
8670
9734
  }
8671
9735
  function readManifest(productDir, file, key) {
8672
9736
  return parseLintPolicyManifest(
8673
- readFileSync4(join25(productDir, file), "utf-8"),
9737
+ readFileSync4(join26(productDir, file), "utf-8"),
8674
9738
  file,
8675
9739
  key
8676
9740
  );
8677
9741
  }
8678
9742
  function manifestExists(productDir, file) {
8679
- return existsSync4(join25(productDir, file));
9743
+ return existsSync4(join26(productDir, file));
8680
9744
  }
8681
9745
  function findDeprecatedSpecNodePath(productDir) {
8682
9746
  function visit2(relativeDirectory) {
8683
- const absoluteDirectory = join25(productDir, relativeDirectory);
9747
+ const absoluteDirectory = join26(productDir, relativeDirectory);
8684
9748
  for (const entry of readdirSync2(absoluteDirectory, { withFileTypes: true })) {
8685
9749
  if (!entry.isDirectory()) {
8686
9750
  continue;
@@ -8696,7 +9760,7 @@ function findDeprecatedSpecNodePath(productDir) {
8696
9760
  }
8697
9761
  return void 0;
8698
9762
  }
8699
- const specTreeRootPath = join25(productDir, SPEC_TREE_ROOT);
9763
+ const specTreeRootPath = join26(productDir, SPEC_TREE_ROOT);
8700
9764
  if (!existsSync4(specTreeRootPath)) {
8701
9765
  return void 0;
8702
9766
  }
@@ -8722,7 +9786,7 @@ function assertManifestEntries(productDir, file, entries, suffixPredicate, suffi
8722
9786
  if (!suffixPredicate(entry)) {
8723
9787
  throw new Error(`${file} entry must ${suffixDescription}: ${entry}`);
8724
9788
  }
8725
- const absoluteEntry = join25(productDir, entry);
9789
+ const absoluteEntry = join26(productDir, entry);
8726
9790
  if (!existsSync4(absoluteEntry) || !statSync2(absoluteEntry).isDirectory()) {
8727
9791
  throw new Error(`${file} entry does not exist as a directory: ${entry}`);
8728
9792
  }
@@ -8982,7 +10046,7 @@ async function validateESLint(context, runner = defaultEslintProcessRunner, outp
8982
10046
  scopeConfig: context.scopeConfig
8983
10047
  });
8984
10048
  return new Promise((resolve6) => {
8985
- const localBin = join26(projectRoot, ...ESLINT_LOCAL_BIN_SEGMENTS);
10049
+ const localBin = join27(projectRoot, ...ESLINT_LOCAL_BIN_SEGMENTS);
8986
10050
  const binary = existsSync5(localBin) ? localBin : "npx";
8987
10051
  const spawnArgs = binary === "npx" ? eslintArgs : eslintArgs.slice(1);
8988
10052
  const eslintProcess = spawnManagedSubprocess(runner, binary, spawnArgs, {
@@ -9084,7 +10148,7 @@ async function lintCommand(options) {
9084
10148
 
9085
10149
  // src/validation/literal/index.ts
9086
10150
  import { readFile as readFile8 } from "fs/promises";
9087
- import { isAbsolute as isAbsolute5, relative as relative5, resolve as resolve4 } from "path";
10151
+ import { isAbsolute as isAbsolute5, relative as relative5, resolve as resolve5 } from "path";
9088
10152
 
9089
10153
  // src/lib/file-inclusion/predicates/ignore-source.ts
9090
10154
  var IGNORE_SOURCE_LAYER = "ignore-source";
@@ -9119,7 +10183,7 @@ var ignoreSourceLayer = makeLayer(
9119
10183
 
9120
10184
  // src/lib/file-inclusion/pipeline.ts
9121
10185
  import { readdir as readdir7 } from "fs/promises";
9122
- import { join as join27, relative as relative4, sep as sep2 } from "path";
10186
+ import { join as join28, relative as relative4, sep as sep2 } from "path";
9123
10187
  var EXPLICIT_OVERRIDE_LAYER = "explicit-override";
9124
10188
  function isNodeError3(err) {
9125
10189
  return err instanceof Error && "code" in err;
@@ -9137,10 +10201,10 @@ async function collectPaths(absoluteDir, projectRoot, result, artifactDirs) {
9137
10201
  for (const entry of dirEntries) {
9138
10202
  if (entry.isDirectory()) {
9139
10203
  if (artifactDirs.has(entry.name)) continue;
9140
- const absolutePath = join27(absoluteDir, entry.name);
10204
+ const absolutePath = join28(absoluteDir, entry.name);
9141
10205
  await collectPaths(absolutePath, projectRoot, result, artifactDirs);
9142
10206
  } else if (entry.isFile()) {
9143
- const absolutePath = join27(absoluteDir, entry.name);
10207
+ const absolutePath = join28(absoluteDir, entry.name);
9144
10208
  const rel = relative4(projectRoot, absolutePath);
9145
10209
  result.push(sep2 === "/" ? rel : rel.split(sep2).join("/"));
9146
10210
  }
@@ -9547,7 +10611,7 @@ async function validateLiteralReuse(input) {
9547
10611
  const config = input.config ?? literalConfigDescriptor.defaults;
9548
10612
  const request = input.files ? {
9549
10613
  explicit: input.files.map((f) => {
9550
- const abs = isAbsolute5(f) ? f : resolve4(input.productDir, f);
10614
+ const abs = isAbsolute5(f) ? f : resolve5(input.productDir, f);
9551
10615
  return relative5(input.productDir, abs).split(/[\\/]/g).join("/");
9552
10616
  })
9553
10617
  } : { walkRoot: input.productDir };
@@ -9559,7 +10623,7 @@ async function validateLiteralReuse(input) {
9559
10623
  EMPTY_IGNORE_READER
9560
10624
  );
9561
10625
  const filtered = applyPathFilter2(scope.included, input.pathConfig);
9562
- const candidateFiles = filtered.filter((entry) => isTypescriptSource(entry.path)).map((entry) => resolve4(input.productDir, entry.path));
10626
+ const candidateFiles = filtered.filter((entry) => isTypescriptSource(entry.path)).map((entry) => resolve5(input.productDir, entry.path));
9563
10627
  const collectOptions = {
9564
10628
  visitorKeys: defaultVisitorKeys,
9565
10629
  minStringLength: config.minStringLength,
@@ -9800,7 +10864,7 @@ function formatLoc(loc) {
9800
10864
  // src/validation/steps/typescript.ts
9801
10865
  import { existsSync as existsSync6, mkdirSync, rmSync, writeFileSync } from "fs";
9802
10866
  import { mkdtemp as mkdtemp2 } from "fs/promises";
9803
- import { isAbsolute as isAbsolute6, join as join28 } from "path";
10867
+ import { isAbsolute as isAbsolute6, join as join29 } from "path";
9804
10868
  var defaultTypeScriptProcessRunner = lifecycleProcessRunner;
9805
10869
  var defaultTypeScriptDeps = {
9806
10870
  mkdtemp: mkdtemp2,
@@ -9813,9 +10877,9 @@ var TEMPORARY_TSCONFIG_COMPILER_OPTIONS = { noEmit: true };
9813
10877
  var TEMPORARY_TSCONFIG_PARENT_SEGMENTS = ["node_modules", ".cache", "spx"];
9814
10878
  var TEMPORARY_TSCONFIG_DIR_PREFIX = "validate-ts-";
9815
10879
  async function createTemporaryTsconfigDir(projectRoot, deps) {
9816
- const parent = join28(projectRoot, ...TEMPORARY_TSCONFIG_PARENT_SEGMENTS);
10880
+ const parent = join29(projectRoot, ...TEMPORARY_TSCONFIG_PARENT_SEGMENTS);
9817
10881
  deps.mkdirSync(parent, { recursive: true });
9818
- return deps.mkdtemp(join28(parent, TEMPORARY_TSCONFIG_DIR_PREFIX));
10882
+ return deps.mkdtemp(join29(parent, TEMPORARY_TSCONFIG_DIR_PREFIX));
9819
10883
  }
9820
10884
  function buildTypeScriptArgs(context) {
9821
10885
  const { scope, configFile } = context;
@@ -9823,11 +10887,11 @@ function buildTypeScriptArgs(context) {
9823
10887
  }
9824
10888
  async function createFileSpecificTsconfig(scope, files, projectRoot, deps = defaultTypeScriptDeps) {
9825
10889
  const tempDir = await createTemporaryTsconfigDir(projectRoot, deps);
9826
- const configPath = join28(tempDir, "tsconfig.json");
10890
+ const configPath = join29(tempDir, "tsconfig.json");
9827
10891
  const baseConfigFile = TSCONFIG_FILES[scope];
9828
- const absoluteFiles = files.map((file) => isAbsolute6(file) ? file : join28(projectRoot, file));
10892
+ const absoluteFiles = files.map((file) => isAbsolute6(file) ? file : join29(projectRoot, file));
9829
10893
  const tempConfig = {
9830
- extends: join28(projectRoot, baseConfigFile),
10894
+ extends: join29(projectRoot, baseConfigFile),
9831
10895
  files: absoluteFiles,
9832
10896
  include: [],
9833
10897
  exclude: [],
@@ -9844,11 +10908,11 @@ async function createFileSpecificTsconfig(scope, files, projectRoot, deps = defa
9844
10908
  }
9845
10909
  async function createScopeFilteredTsconfig(scope, projectRoot, scopeConfig, deps = defaultTypeScriptDeps) {
9846
10910
  const tempDir = await createTemporaryTsconfigDir(projectRoot, deps);
9847
- const configPath = join28(tempDir, "tsconfig.json");
10911
+ const configPath = join29(tempDir, "tsconfig.json");
9848
10912
  const baseConfigFile = TSCONFIG_FILES[scope];
9849
- const toProjectPathPattern = (pattern) => isAbsolute6(pattern) ? pattern : join28(projectRoot, pattern);
10913
+ const toProjectPathPattern = (pattern) => isAbsolute6(pattern) ? pattern : join29(projectRoot, pattern);
9850
10914
  const tempConfig = {
9851
- extends: join28(projectRoot, baseConfigFile),
10915
+ extends: join29(projectRoot, baseConfigFile),
9852
10916
  include: scopeConfig.filePatterns.map(toProjectPathPattern),
9853
10917
  exclude: scopeConfig.excludePatterns.map(toProjectPathPattern),
9854
10918
  compilerOptions: TEMPORARY_TSCONFIG_COMPILER_OPTIONS
@@ -9876,7 +10940,7 @@ async function validateTypeScript(context, options = {}) {
9876
10940
  const { configPath, cleanup } = await createFileSpecificTsconfig(scope, files, projectRoot, deps);
9877
10941
  try {
9878
10942
  return await new Promise((resolve6) => {
9879
- const tscBin = join28(projectRoot, "node_modules", ".bin", "tsc");
10943
+ const tscBin = join29(projectRoot, "node_modules", ".bin", "tsc");
9880
10944
  const tscBinary = deps.existsSync(tscBin) ? tscBin : "npx";
9881
10945
  const tscArgs2 = tscBinary === "npx" ? ["tsc", "--project", configPath] : ["--project", configPath];
9882
10946
  const tscProcess = spawnManagedSubprocess(runner, tscBinary, tscArgs2, {
@@ -9909,7 +10973,7 @@ async function validateTypeScript(context, options = {}) {
9909
10973
  return { success: true, skipped: true };
9910
10974
  }
9911
10975
  const { configPath, cleanup } = await createScopeFilteredTsconfig(scope, projectRoot, scopeConfig, deps);
9912
- const tscBin = join28(projectRoot, "node_modules", ".bin", "tsc");
10976
+ const tscBin = join29(projectRoot, "node_modules", ".bin", "tsc");
9913
10977
  tool = deps.existsSync(tscBin) ? tscBin : "npx";
9914
10978
  tscArgs = tool === "npx" ? ["tsc", "--project", configPath] : ["--project", configPath];
9915
10979
  return new Promise((resolve6) => {
@@ -9931,7 +10995,7 @@ async function validateTypeScript(context, options = {}) {
9931
10995
  });
9932
10996
  });
9933
10997
  } else {
9934
- const tscBin = join28(projectRoot, "node_modules", ".bin", "tsc");
10998
+ const tscBin = join29(projectRoot, "node_modules", ".bin", "tsc");
9935
10999
  tool = deps.existsSync(tscBin) ? tscBin : "npx";
9936
11000
  const rawArgs = buildTypeScriptArgs({ scope, configFile });
9937
11001
  tscArgs = tool === "npx" ? rawArgs : rawArgs.slice(1);
@@ -10024,6 +11088,13 @@ async function typescriptCommand(options) {
10024
11088
 
10025
11089
  // src/validation/languages/typescript.ts
10026
11090
  var TYPESCRIPT_LANGUAGE_NAME = "typescript";
11091
+ async function runCircularStage(context) {
11092
+ if (context.skipCircular) {
11093
+ const skipOutput = context.json ? CIRCULAR_SKIP_JSON_OUTPUT : CIRCULAR_SKIP_OUTPUT;
11094
+ return { exitCode: 0, output: context.quiet ? "" : skipOutput };
11095
+ }
11096
+ return circularCommand({ cwd: context.cwd, quiet: context.quiet, json: context.json });
11097
+ }
10027
11098
  async function runLiteralStage(context) {
10028
11099
  if (context.skipLiteral) {
10029
11100
  const skipOutput = context.json ? LITERAL_SKIP_JSON_OUTPUT : LITERAL_SKIP_OUTPUT;
@@ -10042,7 +11113,7 @@ var typescriptValidationLanguage = {
10042
11113
  {
10043
11114
  name: VALIDATION_STAGE_DISPLAY_NAMES.CIRCULAR,
10044
11115
  failsPipeline: true,
10045
- run: (context) => circularCommand({ cwd: context.cwd, quiet: context.quiet, json: context.json })
11116
+ run: runCircularStage
10046
11117
  },
10047
11118
  {
10048
11119
  name: VALIDATION_STAGE_DISPLAY_NAMES.KNIP,
@@ -10122,11 +11193,11 @@ function formatStepWithTiming(stepNumber, result, quiet) {
10122
11193
  return `[${stepNumber}/${VALIDATION_PIPELINE_TOTAL_STEPS}] ${result.output}${timing}`;
10123
11194
  }
10124
11195
  async function allCommand(options) {
10125
- const { cwd, scope, files, fix, quiet = false, json, skipLiteral = false } = options;
11196
+ const { cwd, scope, files, fix, quiet = false, json, skipCircular = false, skipLiteral = false } = options;
10126
11197
  const startTime = Date.now();
10127
11198
  const outputs = [];
10128
11199
  let hasFailure = false;
10129
- const context = { cwd, scope, files, fix, quiet, json, skipLiteral };
11200
+ const context = { cwd, scope, files, fix, quiet, json, skipCircular, skipLiteral };
10130
11201
  let stepNumber = 0;
10131
11202
  for (const stage of validationPipelineStages) {
10132
11203
  stepNumber += 1;
@@ -10139,59 +11210,17 @@ async function allCommand(options) {
10139
11210
  if (!quiet) {
10140
11211
  const summary = formatSummary({ success: !hasFailure, totalDurationMs });
10141
11212
  outputs.push("", summary);
10142
- }
10143
- return {
10144
- exitCode: hasFailure ? 1 : 0,
10145
- output: outputs.join("\n"),
10146
- durationMs: totalDurationMs
10147
- };
10148
- }
10149
-
10150
- // src/interfaces/cli/sanitize.ts
10151
- var MAX_CLI_ARGUMENT_DISPLAY_LENGTH = 120;
10152
- var ELLIPSIS_TOKEN = "...";
10153
- var SENTINEL_UNDEFINED = "<undefined>";
10154
- var SENTINEL_NULL = "<null>";
10155
- var SENTINEL_EMPTY = "<empty>";
10156
- var CONTROL_CHAR_UPPER_BOUND = 31;
10157
- var DEL_CHAR_CODE = 127;
10158
- var HEX_RADIX = 16;
10159
- var HEX_PAD = 2;
10160
- function formatHexEscape(code) {
10161
- return `\\x${code.toString(HEX_RADIX).padStart(HEX_PAD, "0")}`;
10162
- }
10163
- function nonStringSentinel(type) {
10164
- return `<non-string:${type}>`;
10165
- }
10166
- function sanitizeCliArgument(input) {
10167
- if (input === void 0) return SENTINEL_UNDEFINED;
10168
- if (input === null) return SENTINEL_NULL;
10169
- if (typeof input !== "string") return nonStringSentinel(typeof input);
10170
- if (input.length === 0) return SENTINEL_EMPTY;
10171
- const escaped = escapeControlCharacters(input);
10172
- return truncate(escaped);
10173
- }
10174
- function escapeControlCharacters(value) {
10175
- let out = "";
10176
- for (const char of value) {
10177
- const code = char.codePointAt(0);
10178
- if (code === void 0) continue;
10179
- if (code <= CONTROL_CHAR_UPPER_BOUND || code === DEL_CHAR_CODE) {
10180
- out += formatHexEscape(code);
10181
- } else {
10182
- out += char;
10183
- }
10184
- }
10185
- return out;
10186
- }
10187
- function truncate(value) {
10188
- if (value.length <= MAX_CLI_ARGUMENT_DISPLAY_LENGTH) return value;
10189
- return value.slice(0, MAX_CLI_ARGUMENT_DISPLAY_LENGTH - ELLIPSIS_TOKEN.length) + ELLIPSIS_TOKEN;
11213
+ }
11214
+ return {
11215
+ exitCode: hasFailure ? 1 : 0,
11216
+ output: outputs.join("\n"),
11217
+ durationMs: totalDurationMs
11218
+ };
10190
11219
  }
10191
11220
 
10192
11221
  // src/validation/literal/allowlist-existing.ts
10193
11222
  import { rename as rename4, writeFile as writeFile4 } from "fs/promises";
10194
- import { dirname as dirname7, join as join29 } from "path";
11223
+ import { dirname as dirname8, join as join30 } from "path";
10195
11224
  var EXIT_OK = 0;
10196
11225
  var EXIT_ERROR = 1;
10197
11226
  var INCLUDE_FIELD = "include";
@@ -10211,9 +11240,9 @@ var productionReader = {
10211
11240
  };
10212
11241
  var productionWriter = {
10213
11242
  async write(filePath, content) {
10214
- const dir = dirname7(filePath);
11243
+ const dir = dirname8(filePath);
10215
11244
  const random = Math.random().toString(RANDOM_BASE).slice(RANDOM_PREFIX_SLICE, RANDOM_PREFIX_SLICE + RANDOM_TOKEN_LENGTH);
10216
- const tmpPath = join29(dir, `${TEMP_FILE_PREFIX}${random}${TEMP_FILE_SUFFIX}`);
11245
+ const tmpPath = join30(dir, `${TEMP_FILE_PREFIX}${random}${TEMP_FILE_SUFFIX}`);
10217
11246
  await writeFile4(tmpPath, content, "utf8");
10218
11247
  await rename4(tmpPath, filePath);
10219
11248
  }
@@ -10363,6 +11392,10 @@ var literalValidationCliOptions = {
10363
11392
  }
10364
11393
  };
10365
11394
  var allValidationCliOptions = {
11395
+ skipCircular: {
11396
+ flag: "--skip-circular",
11397
+ description: "Skip circular dependency detection for this validation all run"
11398
+ },
10366
11399
  skipLiteral: {
10367
11400
  flag: "--skip-literal",
10368
11401
  description: "Skip literal reuse detection for this validation all run"
@@ -10491,12 +11524,13 @@ function registerValidationCommands(validationCmd) {
10491
11524
  process.exit(result.exitCode);
10492
11525
  });
10493
11526
  addCommonOptions(markdownCmd);
10494
- const allCmd = addValidationSubcommand(validationCmd, subcommands.all).option("--fix", "Auto-fix ESLint issues").option(allValidationCliOptions.skipLiteral.flag, allValidationCliOptions.skipLiteral.description).action(async (options) => {
11527
+ const allCmd = addValidationSubcommand(validationCmd, subcommands.all).option("--fix", "Auto-fix ESLint issues").option(allValidationCliOptions.skipCircular.flag, allValidationCliOptions.skipCircular.description).option(allValidationCliOptions.skipLiteral.flag, allValidationCliOptions.skipLiteral.description).action(async (options) => {
10495
11528
  const result = await allCommand({
10496
11529
  cwd: process.cwd(),
10497
11530
  scope: options.scope,
10498
11531
  files: options.files,
10499
11532
  fix: options.fix,
11533
+ skipCircular: options.skipCircular,
10500
11534
  skipLiteral: options.skipLiteral,
10501
11535
  quiet: options.quiet,
10502
11536
  json: options.json
@@ -10534,284 +11568,9 @@ var validationDomain = {
10534
11568
  }
10535
11569
  };
10536
11570
 
10537
- // src/domains/worktree/controlling-process.ts
10538
- var CONTROLLING_PID_ENV = "SPX_WORKTREE_CONTROLLING_PID";
10539
- var AGENT_RUNTIME_NAMES = ["claude", "codex"];
10540
- var AGENT_COMMAND_PATTERN = new RegExp(`\\b(?:${AGENT_RUNTIME_NAMES.join("|")})\\b`, "i");
10541
- var CONTROLLING_PROCESS_ERROR = {
10542
- UNRESOLVED: "worktree controlling process could not be resolved"
10543
- };
10544
- var MAX_ANCESTRY_DEPTH = 64;
10545
- var PID_RADIX = 10;
10546
- function resolveControllingProcess(selfPid, table, env) {
10547
- const host = table.currentHost();
10548
- for (const pid of controllingPidCandidates(selfPid, table, env)) {
10549
- const startedAt = table.startTimeOf(pid);
10550
- if (startedAt !== void 0) return { ok: true, value: { pid, startedAt, host } };
10551
- }
10552
- return { ok: false, error: CONTROLLING_PROCESS_ERROR.UNRESOLVED };
10553
- }
10554
- function* controllingPidCandidates(selfPid, table, env) {
10555
- const override = parsePid(env[CONTROLLING_PID_ENV]);
10556
- if (override !== void 0) yield override;
10557
- const agent = findAgentAncestor(selfPid, table);
10558
- if (agent !== void 0) yield agent;
10559
- const parent = table.parentOf(selfPid);
10560
- if (parent !== void 0) yield parent;
10561
- }
10562
- function findAgentAncestor(selfPid, table) {
10563
- let pid = table.parentOf(selfPid);
10564
- for (let depth = 0; pid !== void 0 && depth < MAX_ANCESTRY_DEPTH; depth += 1) {
10565
- const command = table.commandOf(pid);
10566
- if (command !== void 0 && AGENT_COMMAND_PATTERN.test(command)) return pid;
10567
- pid = table.parentOf(pid);
10568
- }
10569
- return void 0;
10570
- }
10571
- function parsePid(value) {
10572
- if (value === void 0 || value.length === 0) return void 0;
10573
- const parsed = Number.parseInt(value, PID_RADIX);
10574
- return Number.isInteger(parsed) && parsed > 0 ? parsed : void 0;
10575
- }
10576
-
10577
- // src/domains/worktree/occupancy-store.ts
10578
- import {
10579
- mkdir as nodeMkdir2,
10580
- readFile as nodeReadFile2,
10581
- rename as nodeRename,
10582
- rm as nodeRm,
10583
- writeFile as nodeWriteFile2
10584
- } from "fs/promises";
10585
- import { join as join30 } from "path";
10586
- var OCCUPANCY_STATUS = {
10587
- UNCLAIMED: "unclaimed",
10588
- OCCUPIED: "occupied",
10589
- STALE: "stale"
10590
- };
10591
- var OCCUPANCY_CLAIM = {
10592
- FILE_EXTENSION: ".claim",
10593
- TEMP_EXTENSION: ".tmp"
10594
- };
10595
- var OCCUPANCY_ERROR = {
10596
- INVALID_NAME: "worktree occupancy claim name must be a safe path segment",
10597
- CLAIM_WRITE_FAILED: "worktree occupancy claim write failed",
10598
- CLAIM_READ_FAILED: "worktree occupancy claim read failed",
10599
- CLAIM_REMOVE_FAILED: "worktree occupancy claim remove failed",
10600
- CLAIM_MALFORMED: "worktree occupancy claim record is malformed"
10601
- };
10602
- var ERROR_DETAIL_SEPARATOR2 = ": ";
10603
- var defaultOccupancyFileSystem = {
10604
- mkdir: async (path6, options) => {
10605
- await nodeMkdir2(path6, options);
10606
- },
10607
- writeFile: async (path6, data) => {
10608
- await nodeWriteFile2(path6, data);
10609
- },
10610
- rename: nodeRename,
10611
- readFile: nodeReadFile2,
10612
- rm: async (path6, options) => {
10613
- await nodeRm(path6, options);
10614
- }
10615
- };
10616
- function claimFileName(name) {
10617
- return `${name}${OCCUPANCY_CLAIM.FILE_EXTENSION}`;
10618
- }
10619
- function claimFilePath(worktreesDir, name) {
10620
- const validated = validateScopeToken(name);
10621
- if (!validated.ok) return { ok: false, error: OCCUPANCY_ERROR.INVALID_NAME };
10622
- return { ok: true, value: join30(worktreesDir, claimFileName(validated.value)) };
10623
- }
10624
- function classifyOccupancy(claim, probe) {
10625
- if (claim === void 0) return OCCUPANCY_STATUS.UNCLAIMED;
10626
- if (claim.host !== probe.currentHost()) return OCCUPANCY_STATUS.STALE;
10627
- if (!probe.isAlive(claim.pid)) return OCCUPANCY_STATUS.STALE;
10628
- const liveStartTime = probe.startTimeOf(claim.pid);
10629
- if (liveStartTime !== void 0 && liveStartTime !== claim.startedAt) return OCCUPANCY_STATUS.STALE;
10630
- return OCCUPANCY_STATUS.OCCUPIED;
10631
- }
10632
- async function writeClaim(worktreesDir, name, record, options = {}) {
10633
- const fs7 = options.fs ?? defaultOccupancyFileSystem;
10634
- const pathResult = claimFilePath(worktreesDir, name);
10635
- if (!pathResult.ok) return pathResult;
10636
- const claimPath = pathResult.value;
10637
- const tempPath = `${claimPath}${OCCUPANCY_CLAIM.TEMP_EXTENSION}`;
10638
- try {
10639
- await fs7.mkdir(worktreesDir, { recursive: true });
10640
- await fs7.writeFile(tempPath, serializeClaim(record));
10641
- await fs7.rename(tempPath, claimPath);
10642
- return { ok: true, value: claimPath };
10643
- } catch (error) {
10644
- return { ok: false, error: formatOccupancyError(OCCUPANCY_ERROR.CLAIM_WRITE_FAILED, toErrorMessage3(error)) };
10645
- }
10646
- }
10647
- async function readClaim(worktreesDir, name, options = {}) {
10648
- const fs7 = options.fs ?? defaultOccupancyFileSystem;
10649
- const pathResult = claimFilePath(worktreesDir, name);
10650
- if (!pathResult.ok) return pathResult;
10651
- let content;
10652
- try {
10653
- content = await fs7.readFile(pathResult.value, "utf8");
10654
- } catch (error) {
10655
- if (hasErrorCode(error, ERROR_CODE_NOT_FOUND)) return { ok: true, value: void 0 };
10656
- return { ok: false, error: formatOccupancyError(OCCUPANCY_ERROR.CLAIM_READ_FAILED, toErrorMessage3(error)) };
10657
- }
10658
- return parseClaim(content);
10659
- }
10660
- async function removeClaim(worktreesDir, name, options = {}) {
10661
- const fs7 = options.fs ?? defaultOccupancyFileSystem;
10662
- const pathResult = claimFilePath(worktreesDir, name);
10663
- if (!pathResult.ok) return pathResult;
10664
- try {
10665
- await fs7.rm(pathResult.value, { force: true });
10666
- return { ok: true, value: void 0 };
10667
- } catch (error) {
10668
- return { ok: false, error: formatOccupancyError(OCCUPANCY_ERROR.CLAIM_REMOVE_FAILED, toErrorMessage3(error)) };
10669
- }
10670
- }
10671
- async function readOccupancy(worktreesDir, name, probe, options = {}) {
10672
- const claimResult = await readClaim(worktreesDir, name, options);
10673
- if (!claimResult.ok) return claimResult;
10674
- return { ok: true, value: classifyOccupancy(claimResult.value, probe) };
10675
- }
10676
- function serializeClaim(record) {
10677
- return JSON.stringify({
10678
- sessionId: record.sessionId,
10679
- host: record.host,
10680
- pid: record.pid,
10681
- startedAt: record.startedAt
10682
- });
10683
- }
10684
- function parseClaim(content) {
10685
- let parsed;
10686
- try {
10687
- parsed = JSON.parse(content);
10688
- } catch {
10689
- return { ok: false, error: OCCUPANCY_ERROR.CLAIM_MALFORMED };
10690
- }
10691
- if (!isWorktreeClaimRecord(parsed)) return { ok: false, error: OCCUPANCY_ERROR.CLAIM_MALFORMED };
10692
- return { ok: true, value: parsed };
10693
- }
10694
- function isWorktreeClaimRecord(value) {
10695
- if (typeof value !== "object" || value === null) return false;
10696
- const candidate = value;
10697
- return typeof candidate.sessionId === "string" && typeof candidate.host === "string" && typeof candidate.pid === "number" && typeof candidate.startedAt === "string";
10698
- }
10699
- function formatOccupancyError(code, detail) {
10700
- return `${code}${ERROR_DETAIL_SEPARATOR2}${detail}`;
10701
- }
10702
- function toErrorMessage3(error) {
10703
- return error instanceof Error ? error.message : String(error);
10704
- }
10705
-
10706
- // src/domains/worktree/process-table.ts
10707
- import { spawnSync } from "child_process";
10708
- import { hostname } from "os";
10709
- var PS_COMMAND = "/bin/ps";
10710
- var PS_FIELD = {
10711
- START_TIME: "lstart",
10712
- PARENT_PID: "ppid",
10713
- // The full command line, not `comm` — `comm` reports only the executable
10714
- // basename, so a shebang-installed agent run through an interpreter reads as
10715
- // the interpreter (e.g. `node`) and the agent-runtime match misses it. `args`
10716
- // carries the script path, so both native and interpreted agents are matched.
10717
- COMMAND: "args"
10718
- };
10719
- var SIGNAL_LIVENESS_PROBE = 0;
10720
- var PROCESS_EXISTS_NO_PERMISSION = "EPERM";
10721
- var PID_RADIX2 = 10;
10722
- var STABLE_PS_ENV = { ...process.env, TZ: "UTC", LC_ALL: "C" };
10723
- function psField(pid, field) {
10724
- const result = spawnSync(PS_COMMAND, ["-o", `${field}=`, "-p", String(pid)], {
10725
- encoding: "utf8",
10726
- env: STABLE_PS_ENV
10727
- });
10728
- if (result.status !== 0 || typeof result.stdout !== "string") return void 0;
10729
- const value = result.stdout.trim();
10730
- return value.length > 0 ? value : void 0;
10731
- }
10732
- var defaultProcessTable = {
10733
- currentHost: () => hostname(),
10734
- isAlive: (pid) => {
10735
- try {
10736
- process.kill(pid, SIGNAL_LIVENESS_PROBE);
10737
- return true;
10738
- } catch (error) {
10739
- return hasErrorCode(error, PROCESS_EXISTS_NO_PERMISSION);
10740
- }
10741
- },
10742
- startTimeOf: (pid) => psField(pid, PS_FIELD.START_TIME),
10743
- parentOf: (pid) => {
10744
- const value = psField(pid, PS_FIELD.PARENT_PID);
10745
- if (value === void 0) return void 0;
10746
- const parsed = Number.parseInt(value, PID_RADIX2);
10747
- return Number.isInteger(parsed) ? parsed : void 0;
10748
- },
10749
- commandOf: (pid) => psField(pid, PS_FIELD.COMMAND)
10750
- };
10751
-
10752
- // src/commands/worktree/resolve.ts
10753
- import { stat as stat4 } from "fs/promises";
10754
- import { dirname as dirname8, resolve as resolve5 } from "path";
10755
-
10756
- // src/domains/worktree/worktree-name.ts
10757
- import { basename as basename4 } from "path";
10758
- var SEPARATOR_CHARACTER = /[^a-z0-9_]/;
10759
- var NAME_SEPARATOR = "-";
10760
- function worktreeClaimName(worktreeRoot) {
10761
- return basename4(worktreeRoot).toLowerCase().split(SEPARATOR_CHARACTER).filter((segment) => segment.length > 0).join(NAME_SEPARATOR);
10762
- }
10763
-
10764
- // src/commands/worktree/resolve.ts
10765
- var WORKTREE_RESOLVE_ERROR = {
10766
- NOT_A_WORKTREE: "path resolves to no worktree"
10767
- };
10768
- async function resolveWorktreesDir(options) {
10769
- if (options.worktreesDir !== void 0) return options.worktreesDir;
10770
- const resolved = await resolveWorktreesScopeDir({ cwd: options.cwd, deps: options.gitDeps });
10771
- options.onWarning?.(resolved.warning);
10772
- return resolved.worktreesDir;
10773
- }
10774
- async function resolveCurrentWorktreeName(options) {
10775
- const worktree = await detectWorktreeProductRoot(options.cwd, options.gitDeps);
10776
- return worktreeClaimName(worktree.productDir);
10777
- }
10778
- async function resolveTargetWorktree(options) {
10779
- const base = options.cwd ?? process.cwd();
10780
- const targetPath = options.worktree === void 0 ? base : resolve5(base, options.worktree);
10781
- const targetGitPath = await isExistingNonDirectory(targetPath) ? dirname8(targetPath) : targetPath;
10782
- const worktree = await detectWorktreeProductRoot(targetGitPath, options.gitDeps);
10783
- if (!worktree.isGitRepo) {
10784
- return { ok: false, error: `${WORKTREE_RESOLVE_ERROR.NOT_A_WORKTREE}: ${options.worktree ?? base}` };
10785
- }
10786
- return { ok: true, value: { name: worktreeClaimName(worktree.productDir), worktreeRoot: worktree.productDir } };
10787
- }
10788
- async function isExistingNonDirectory(path6) {
10789
- try {
10790
- const pathStats = await stat4(path6);
10791
- return !pathStats.isDirectory();
10792
- } catch {
10793
- return false;
10794
- }
10795
- }
10796
-
10797
11571
  // src/commands/worktree/claim.ts
10798
11572
  async function claimCommand(options) {
10799
- const table = options.processTable ?? defaultProcessTable;
10800
- const controlling = resolveControllingProcess(options.selfPid ?? process.pid, table, options.env ?? process.env);
10801
- if (!controlling.ok) return controlling;
10802
- const worktreesDir = await resolveWorktreesDir(options);
10803
- const name = await resolveCurrentWorktreeName(options);
10804
- return writeClaim(
10805
- worktreesDir,
10806
- name,
10807
- {
10808
- sessionId: options.sessionId,
10809
- host: controlling.value.host,
10810
- pid: controlling.value.pid,
10811
- startedAt: controlling.value.startedAt
10812
- },
10813
- { fs: options.fs }
10814
- );
11573
+ return claimWorktreeOccupancy(options);
10815
11574
  }
10816
11575
 
10817
11576
  // src/commands/worktree/release.ts
@@ -10827,14 +11586,13 @@ var WORKTREE_STATUS_FORMAT = {
10827
11586
  TEXT: "text"
10828
11587
  };
10829
11588
  async function statusCommand2(options) {
10830
- const table = options.processTable ?? defaultProcessTable;
10831
11589
  const multiTargetRequest = options.worktrees !== void 0 && options.worktrees.length > 1;
10832
11590
  const targets = await resolveStatusTargets(options);
10833
11591
  if (!targets.ok) return targets;
10834
11592
  const records = [];
10835
11593
  for (const target of targets.value) {
10836
11594
  const worktreesDir = await resolveWorktreesDir({ ...options, cwd: target.worktreeRoot });
10837
- const occupancy = await readOccupancy(worktreesDir, target.name, table, { fs: options.fs });
11595
+ const occupancy = await readOccupancy(worktreesDir, target.name, options.processTable, { fs: options.fs });
10838
11596
  if (!occupancy.ok) return occupancy;
10839
11597
  records.push({ worktree: target.name, status: occupancy.value });
10840
11598
  }
@@ -10872,6 +11630,19 @@ function renderTextStatus(record) {
10872
11630
  return `${record.worktree} ${record.status}`;
10873
11631
  }
10874
11632
 
11633
+ // src/lib/worktree-path-info.ts
11634
+ import { stat as stat4 } from "fs/promises";
11635
+ var defaultWorktreePathInfo = {
11636
+ isExistingNonDirectory: async (path6) => {
11637
+ try {
11638
+ const pathStats = await stat4(path6);
11639
+ return !pathStats.isDirectory();
11640
+ } catch {
11641
+ return false;
11642
+ }
11643
+ }
11644
+ };
11645
+
10875
11646
  // src/interfaces/cli/worktree.ts
10876
11647
  var WORKTREE_CLI = {
10877
11648
  COMMAND: "worktree",
@@ -10891,6 +11662,13 @@ function handleError2(error) {
10891
11662
  function registerWorktreeCommands(worktreeCmd) {
10892
11663
  worktreeCmd.command(WORKTREE_CLI.CLAIM).description("Record a worktree-occupancy claim for the running worktree").requiredOption(`${WORKTREE_CLI.SESSION_ID_FLAG} <id>`, "Claiming agent session id").option(`${WORKTREE_CLI.WORKTREES_DIR_FLAG} <path>`, "Explicit .spx/worktrees directory").action(async (options) => {
10893
11664
  const result = await claimCommand({
11665
+ claimWriteToken: createClaimWriteToken(),
11666
+ cwd: process.cwd(),
11667
+ env: process.env,
11668
+ fs: defaultOccupancyFileSystem,
11669
+ gitDeps: defaultGitDependencies,
11670
+ processTable: defaultProcessTable,
11671
+ selfPid: process.pid,
10894
11672
  sessionId: options.sessionId,
10895
11673
  worktreesDir: options.worktreesDir,
10896
11674
  onWarning: writeWarning
@@ -10899,8 +11677,13 @@ function registerWorktreeCommands(worktreeCmd) {
10899
11677
  });
10900
11678
  worktreeCmd.command(`${WORKTREE_CLI.STATUS} ${WORKTREE_CLI.WORKTREE_ARGUMENT}`).description("Report a worktree's occupancy (occupied | unclaimed | stale)").option(`${WORKTREE_CLI.FORMAT_FLAG} <format>`, "Output format (text|json)", WORKTREE_STATUS_FORMAT.TEXT).option(`${WORKTREE_CLI.WORKTREES_DIR_FLAG} <path>`, "Explicit .spx/worktrees directory").action(async (worktrees, options) => {
10901
11679
  const result = await statusCommand2({
11680
+ cwd: process.cwd(),
11681
+ fs: defaultOccupancyFileSystem,
11682
+ gitDeps: defaultGitDependencies,
10902
11683
  worktrees,
10903
11684
  format: options.format,
11685
+ pathInfo: defaultWorktreePathInfo,
11686
+ processTable: defaultProcessTable,
10904
11687
  worktreesDir: options.worktreesDir,
10905
11688
  onWarning: writeWarning
10906
11689
  });
@@ -10908,7 +11691,13 @@ function registerWorktreeCommands(worktreeCmd) {
10908
11691
  console.log(result.value);
10909
11692
  });
10910
11693
  worktreeCmd.command(WORKTREE_CLI.RELEASE).description("Release the running worktree's occupancy claim").option(`${WORKTREE_CLI.WORKTREES_DIR_FLAG} <path>`, "Explicit .spx/worktrees directory").action(async (options) => {
10911
- const result = await releaseCommand2({ worktreesDir: options.worktreesDir, onWarning: writeWarning });
11694
+ const result = await releaseCommand2({
11695
+ cwd: process.cwd(),
11696
+ fs: defaultOccupancyFileSystem,
11697
+ gitDeps: defaultGitDependencies,
11698
+ worktreesDir: options.worktreesDir,
11699
+ onWarning: writeWarning
11700
+ });
10912
11701
  if (!result.ok) handleError2(result.error);
10913
11702
  });
10914
11703
  }
@@ -10926,6 +11715,7 @@ var CLI_DOMAINS = [
10926
11715
  claudeDomain,
10927
11716
  compactDomain,
10928
11717
  configDomain,
11718
+ hookDomain,
10929
11719
  sessionDomain,
10930
11720
  specDomain,
10931
11721
  testingDomain,