@outcomeeng/spx 0.6.2 → 0.6.3

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
@@ -49,38 +49,44 @@ async function findSettingsFiles(root, visited = /* @__PURE__ */ new Set()) {
49
49
  }
50
50
  visited.add(normalizedRoot);
51
51
  try {
52
- const stats = await fs.stat(normalizedRoot);
53
- if (!stats.isDirectory()) {
54
- throw new Error(`Path is not a directory: ${normalizedRoot}`);
55
- }
56
- const entries = await fs.readdir(normalizedRoot, { withFileTypes: true });
57
- const results = [];
58
- for (const entry of entries) {
59
- const fullPath = path.join(normalizedRoot, entry.name);
60
- if (entry.isDirectory() && entry.name === ".claude") {
61
- const settingsPath = path.join(fullPath, "settings.local.json");
62
- if (await isValidSettingsFile(settingsPath)) {
63
- results.push(settingsPath);
64
- }
65
- }
66
- if (entry.isDirectory() && entry.name !== ".claude") {
67
- const subFiles = await findSettingsFiles(fullPath, visited);
68
- results.push(...subFiles);
69
- }
70
- }
71
- return results;
52
+ return await findSettingsFilesInDirectory(normalizedRoot, visited);
72
53
  } catch (error) {
73
- if (error instanceof Error) {
74
- if (error.message.includes("ENOENT")) {
75
- throw new Error(`Directory not found: ${normalizedRoot}`);
76
- }
77
- if (error.message.includes("EACCES")) {
78
- throw new Error(`Permission denied: ${normalizedRoot}`);
79
- }
80
- throw new Error(`Failed to search directory "${normalizedRoot}": ${error.message}`);
81
- }
82
- throw error;
54
+ throw discoveryError(normalizedRoot, error);
55
+ }
56
+ }
57
+ async function findSettingsFilesInDirectory(normalizedRoot, visited) {
58
+ const stats = await fs.stat(normalizedRoot);
59
+ if (!stats.isDirectory()) {
60
+ throw new Error(`Path is not a directory: ${normalizedRoot}`);
61
+ }
62
+ const entries = await fs.readdir(normalizedRoot, { withFileTypes: true });
63
+ const results = [];
64
+ for (const entry of entries) {
65
+ results.push(...await settingsFilesForEntry(normalizedRoot, entry, visited));
66
+ }
67
+ return results;
68
+ }
69
+ async function settingsFilesForEntry(normalizedRoot, entry, visited) {
70
+ if (!entry.isDirectory()) return [];
71
+ const fullPath = path.join(normalizedRoot, entry.name);
72
+ if (entry.name === ".claude") {
73
+ const settingsPath = path.join(fullPath, "settings.local.json");
74
+ return await isValidSettingsFile(settingsPath) ? [settingsPath] : [];
83
75
  }
76
+ return findSettingsFiles(fullPath, visited);
77
+ }
78
+ function discoveryError(normalizedRoot, error) {
79
+ if (!(error instanceof Error)) return new Error(unknownErrorMessage(error));
80
+ if (error.message.includes("ENOENT")) return new Error(`Directory not found: ${normalizedRoot}`);
81
+ if (error.message.includes("EACCES")) return new Error(`Permission denied: ${normalizedRoot}`);
82
+ return new Error(`Failed to search directory "${normalizedRoot}": ${error.message}`);
83
+ }
84
+ function unknownErrorMessage(error) {
85
+ if (typeof error === "string") return error;
86
+ if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") return error.toString();
87
+ if (typeof error === "symbol") return error.description ?? "Symbol()";
88
+ if (error === void 0) return "undefined";
89
+ return Object.prototype.toString.call(error);
84
90
  }
85
91
  async function isValidSettingsFile(filePath) {
86
92
  try {
@@ -801,6 +807,7 @@ function toErrorMessage(error) {
801
807
 
802
808
  // src/lib/claude/permissions/parser.ts
803
809
  import fs2 from "fs/promises";
810
+ var PERMISSION_PATTERN = /^([^(]+)\((.+)\)$/;
804
811
  async function parseSettingsFile(filePath) {
805
812
  try {
806
813
  const content = await fs2.readFile(filePath, "utf-8");
@@ -814,7 +821,7 @@ async function parseSettingsFile(filePath) {
814
821
  }
815
822
  }
816
823
  function parsePermission(raw, category) {
817
- const match = raw.match(/^([^(]+)\((.+)\)$/);
824
+ const match = PERMISSION_PATTERN.exec(raw);
818
825
  if (!match) {
819
826
  throw new Error(`Malformed permission string: "${raw}"`);
820
827
  }
@@ -1095,88 +1102,91 @@ async function createBackup(settingsPath) {
1095
1102
 
1096
1103
  // src/lib/claude/settings/reporter.ts
1097
1104
  function formatReport(result, previewOnly, globalSettingsPath, outputFile) {
1098
- const lines = [];
1099
- lines.push("Scanning for Claude Code settings files...");
1100
- lines.push("");
1101
- lines.push(`Found ${result.filesScanned} settings files`);
1102
- lines.push(` Processed: ${result.filesProcessed}`);
1103
- if (result.filesSkipped > 0) {
1104
- lines.push(` Skipped: ${result.filesSkipped} (no permissions)`);
1105
- }
1106
- lines.push("");
1107
1105
  const totalAdded = result.added.allow.length + result.added.deny.length + result.added.ask.length;
1108
- if (totalAdded > 0) {
1109
- lines.push(`Permissions to add: ${totalAdded}`);
1110
- if (result.added.allow.length > 0) {
1111
- lines.push("");
1112
- lines.push(" allow:");
1113
- for (const perm of result.added.allow) {
1114
- lines.push(` + ${perm}`);
1115
- }
1116
- }
1117
- if (result.added.deny.length > 0) {
1118
- lines.push("");
1119
- lines.push(" deny:");
1120
- for (const perm of result.added.deny) {
1121
- lines.push(` + ${perm}`);
1122
- }
1123
- }
1124
- if (result.added.ask.length > 0) {
1125
- lines.push("");
1126
- lines.push(" ask:");
1127
- for (const perm of result.added.ask) {
1128
- lines.push(` + ${perm}`);
1129
- }
1130
- }
1131
- } else {
1132
- lines.push("No new permissions to add (all permissions already in global settings)");
1133
- }
1134
- lines.push("");
1135
- if (result.subsumed.length > 0) {
1136
- lines.push(`Subsumed permissions removed: ${result.subsumed.length}`);
1137
- lines.push(" (narrower permissions replaced by broader ones)");
1138
- for (const perm of result.subsumed) {
1139
- lines.push(` - ${perm}`);
1140
- }
1141
- lines.push("");
1142
- }
1143
- if (result.conflictsResolved > 0) {
1144
- lines.push(`Conflicts resolved: ${result.conflictsResolved}`);
1145
- lines.push(" (permissions moved from allow to deny)");
1146
- lines.push("");
1147
- }
1148
- if (result.backupPath) {
1149
- lines.push(`Backup created: ${result.backupPath}`);
1150
- lines.push("");
1151
- }
1152
- lines.push("Summary:");
1153
- lines.push(` Files scanned: ${result.filesScanned}`);
1154
- lines.push(
1155
- ` Permissions added: ${result.added.allow.length} allow, ${result.added.deny.length} deny, ${result.added.ask.length} ask`
1156
- );
1157
- if (result.subsumed.length > 0) {
1158
- lines.push(` Subsumed removed: ${result.subsumed.length}`);
1159
- }
1160
- if (result.conflictsResolved > 0) {
1161
- lines.push(` Conflicts resolved: ${result.conflictsResolved}`);
1162
- }
1163
- lines.push("");
1106
+ return [
1107
+ ...headerLines(),
1108
+ ...filesSummaryLines(result),
1109
+ ...permissionLines(result, totalAdded),
1110
+ ...subsumptionLines(result),
1111
+ ...conflictLines(result),
1112
+ ...backupLines(result),
1113
+ ...summaryLines(result),
1114
+ ...finalStatusLines(result, previewOnly, globalSettingsPath, outputFile)
1115
+ ].join("\n");
1116
+ }
1117
+ function headerLines() {
1118
+ return ["Scanning for Claude Code settings files...", ""];
1119
+ }
1120
+ function filesSummaryLines(result) {
1121
+ const lines = [`Found ${result.filesScanned} settings files`, ` Processed: ${result.filesProcessed}`];
1122
+ if (result.filesSkipped > 0) lines.push(` Skipped: ${result.filesSkipped} (no permissions)`);
1123
+ return [...lines, ""];
1124
+ }
1125
+ function permissionLines(result, totalAdded) {
1126
+ if (totalAdded === 0) return ["No new permissions to add (all permissions already in global settings)", ""];
1127
+ return [
1128
+ `Permissions to add: ${totalAdded}`,
1129
+ ...permissionCategoryLines("allow", result.added.allow),
1130
+ ...permissionCategoryLines("deny", result.added.deny),
1131
+ ...permissionCategoryLines("ask", result.added.ask),
1132
+ ""
1133
+ ];
1134
+ }
1135
+ function permissionCategoryLines(label, permissions) {
1136
+ if (permissions.length === 0) return [];
1137
+ return ["", ` ${label}:`, ...permissions.map((permission) => ` + ${permission}`)];
1138
+ }
1139
+ function subsumptionLines(result) {
1140
+ if (result.subsumed.length === 0) return [];
1141
+ return [
1142
+ `Subsumed permissions removed: ${result.subsumed.length}`,
1143
+ " (narrower permissions replaced by broader ones)",
1144
+ ...result.subsumed.map((permission) => ` - ${permission}`),
1145
+ ""
1146
+ ];
1147
+ }
1148
+ function conflictLines(result) {
1149
+ if (result.conflictsResolved === 0) return [];
1150
+ return [`Conflicts resolved: ${result.conflictsResolved}`, " (permissions moved from allow to deny)", ""];
1151
+ }
1152
+ function backupLines(result) {
1153
+ return result.backupPath ? [`Backup created: ${result.backupPath}`, ""] : [];
1154
+ }
1155
+ function summaryLines(result) {
1156
+ return [
1157
+ "Summary:",
1158
+ ` Files scanned: ${result.filesScanned}`,
1159
+ ` Permissions added: ${result.added.allow.length} allow, ${result.added.deny.length} deny, ${result.added.ask.length} ask`,
1160
+ ...optionalSummaryLines(result),
1161
+ ""
1162
+ ];
1163
+ }
1164
+ function optionalSummaryLines(result) {
1165
+ return [
1166
+ ...result.subsumed.length > 0 ? [` Subsumed removed: ${result.subsumed.length}`] : [],
1167
+ ...result.conflictsResolved > 0 ? [` Conflicts resolved: ${result.conflictsResolved}`] : []
1168
+ ];
1169
+ }
1170
+ function finalStatusLines(result, previewOnly, globalSettingsPath, outputFile) {
1164
1171
  if (previewOnly) {
1165
- lines.push("\u2139\uFE0F Preview mode: No changes written");
1166
- lines.push("");
1167
- lines.push("To apply changes:");
1168
- lines.push(` \u2022 Modify global settings: spx claude settings consolidate --write`);
1169
- lines.push(` \u2022 Write to file: spx claude settings consolidate --output-file /path/to/file`);
1170
- } else if (outputFile) {
1171
- lines.push(`\u2713 Settings written to: ${result.outputPath || outputFile}`);
1172
- lines.push("");
1173
- lines.push("To apply to your global settings:");
1174
- lines.push(` \u2022 Review the file, then copy to: ${globalSettingsPath || "~/.claude/settings.json"}`);
1175
- lines.push(` \u2022 Or run: spx claude settings consolidate --write`);
1176
- } else {
1177
- lines.push(`\u2713 Global settings updated: ${globalSettingsPath || "~/.claude/settings.json"}`);
1172
+ return [
1173
+ "\u2139\uFE0F Preview mode: No changes written",
1174
+ "",
1175
+ "To apply changes:",
1176
+ " \u2022 Modify global settings: spx claude settings consolidate --write",
1177
+ " \u2022 Write to file: spx claude settings consolidate --output-file /path/to/file"
1178
+ ];
1178
1179
  }
1179
- return lines.join("\n");
1180
+ if (outputFile) {
1181
+ return [
1182
+ `\u2713 Settings written to: ${result.outputPath || outputFile}`,
1183
+ "",
1184
+ "To apply to your global settings:",
1185
+ ` \u2022 Review the file, then copy to: ${globalSettingsPath || "~/.claude/settings.json"}`,
1186
+ " \u2022 Or run: spx claude settings consolidate --write"
1187
+ ];
1188
+ }
1189
+ return [`\u2713 Global settings updated: ${globalSettingsPath || "~/.claude/settings.json"}`];
1180
1190
  }
1181
1191
 
1182
1192
  // src/lib/claude/settings/writer.ts
@@ -1403,7 +1413,7 @@ var COMPACT_MARKER = {
1403
1413
  FOUNDATION: "SPEC_TREE_FOUNDATION",
1404
1414
  CONTEXT: "SPEC_TREE_CONTEXT",
1405
1415
  TARGET_ATTRIBUTE: "target",
1406
- ESCAPED_TARGET_QUOTE: '\\"',
1416
+ ESCAPED_TARGET_QUOTE: String.raw`\"`,
1407
1417
  UNESCAPED_TARGET_QUOTE: '"'
1408
1418
  };
1409
1419
  var COMPACT_ERROR = {
@@ -1510,8 +1520,8 @@ function readTargetPath(value, startIndex) {
1510
1520
  return value.slice(startIndex, endIndex);
1511
1521
  }
1512
1522
  function isTargetPathCharacter(character) {
1513
- const code = character.charCodeAt(0);
1514
- return code >= 48 && code <= 57 || code >= 65 && code <= 90 || code >= 97 && code <= 122 || character === "." || character === "_" || character === "-" || character === "/";
1523
+ const code = character.codePointAt(0);
1524
+ return code !== void 0 && (code >= 48 && code <= 57 || code >= 65 && code <= 90 || code >= 97 && code <= 122 || character === "." || character === "_" || character === "-" || character === "/");
1515
1525
  }
1516
1526
 
1517
1527
  // src/commands/compact/retrieve.ts
@@ -2826,7 +2836,7 @@ var PATH_SEGMENT_SEPARATOR = "/";
2826
2836
  function normalizePathPrefix(value) {
2827
2837
  let end = value.length;
2828
2838
  while (end > 0 && value[end - 1] === PATH_SEGMENT_SEPARATOR) {
2829
- end--;
2839
+ end -= 1;
2830
2840
  }
2831
2841
  return value.slice(0, end);
2832
2842
  }
@@ -2898,6 +2908,10 @@ var DEFAULT_MIN_STRING_LENGTH = 4;
2898
2908
  var DEFAULT_MIN_NUMBER_DIGITS = 4;
2899
2909
  var LEGACY_LITERAL_ALLOWLIST_FIELD = "allowlist";
2900
2910
  var LEGACY_LITERAL_ALLOWLIST_ERROR = "validation.literal.values.allowlist is no longer valid; move its contents up one level to validation.literal.values.{presets,include,exclude}";
2911
+ var LITERAL_STRING_LIST_FIELDS = {
2912
+ INCLUDE: "include",
2913
+ EXCLUDE: "exclude"
2914
+ };
2901
2915
  var PRESET_NAMES = {
2902
2916
  WEB: "web"
2903
2917
  };
@@ -2946,9 +2960,6 @@ var LITERAL_DEFAULTS = {
2946
2960
  minStringLength: DEFAULT_MIN_STRING_LENGTH,
2947
2961
  minNumberDigits: DEFAULT_MIN_NUMBER_DIGITS
2948
2962
  };
2949
- function isStringArray(value) {
2950
- return Array.isArray(value) && value.every((item) => typeof item === "string");
2951
- }
2952
2963
  function isNonNegativeInteger(value) {
2953
2964
  return typeof value === "number" && Number.isInteger(value) && value >= 0;
2954
2965
  }
@@ -2960,24 +2971,12 @@ function validate7(value) {
2960
2971
  if (candidate[LEGACY_LITERAL_ALLOWLIST_FIELD] !== void 0) {
2961
2972
  return { ok: false, error: LEGACY_LITERAL_ALLOWLIST_ERROR };
2962
2973
  }
2963
- const presets = candidate["presets"];
2964
- if (presets !== void 0) {
2965
- if (!isStringArray(presets)) {
2966
- return { ok: false, error: `${LITERAL_SECTION}.presets must be an array of strings` };
2967
- }
2968
- const unknownPreset = presets.find((id) => !PRESET_REGISTRY.has(id));
2969
- if (unknownPreset !== void 0) {
2970
- return { ok: false, error: `${LITERAL_SECTION}.presets: unrecognized preset "${unknownPreset}"` };
2971
- }
2972
- }
2973
- const include = candidate["include"];
2974
- if (include !== void 0 && !isStringArray(include)) {
2975
- return { ok: false, error: `${LITERAL_SECTION}.include must be an array of strings` };
2976
- }
2977
- const exclude = candidate["exclude"];
2978
- if (exclude !== void 0 && !isStringArray(exclude)) {
2979
- return { ok: false, error: `${LITERAL_SECTION}.exclude must be an array of strings` };
2980
- }
2974
+ const presets = readPresetList(candidate);
2975
+ if (!presets.ok) return presets;
2976
+ const include = readStringList(candidate, LITERAL_STRING_LIST_FIELDS.INCLUDE);
2977
+ if (!include.ok) return include;
2978
+ const exclude = readStringList(candidate, LITERAL_STRING_LIST_FIELDS.EXCLUDE);
2979
+ if (!exclude.ok) return exclude;
2981
2980
  const minStringLength = candidate["minStringLength"] ?? LITERAL_DEFAULTS.minStringLength;
2982
2981
  if (!isNonNegativeInteger(minStringLength)) {
2983
2982
  return {
@@ -2995,14 +2994,36 @@ function validate7(value) {
2995
2994
  return {
2996
2995
  ok: true,
2997
2996
  value: {
2998
- presets,
2999
- include,
3000
- exclude,
2997
+ presets: presets.value,
2998
+ include: include.value,
2999
+ exclude: exclude.value,
3001
3000
  minStringLength,
3002
3001
  minNumberDigits
3003
3002
  }
3004
3003
  };
3005
3004
  }
3005
+ function readPresetList(candidate) {
3006
+ const presets = candidate["presets"];
3007
+ if (presets === void 0) return { ok: true, value: void 0 };
3008
+ if (!isStringArray(presets)) {
3009
+ return { ok: false, error: `${LITERAL_SECTION}.presets must be an array of strings` };
3010
+ }
3011
+ for (const id of presets) {
3012
+ if (!PRESET_REGISTRY.has(id)) {
3013
+ return { ok: false, error: `${LITERAL_SECTION}.presets: unrecognized preset "${id}"` };
3014
+ }
3015
+ }
3016
+ return { ok: true, value: presets };
3017
+ }
3018
+ function readStringList(candidate, field) {
3019
+ const value = candidate[field];
3020
+ if (value === void 0) return { ok: true, value: void 0 };
3021
+ if (!isStringArray(value)) return { ok: false, error: `${LITERAL_SECTION}.${field} must be an array of strings` };
3022
+ return { ok: true, value };
3023
+ }
3024
+ function isStringArray(value) {
3025
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
3026
+ }
3006
3027
  var literalConfigDescriptor = {
3007
3028
  section: LITERAL_SECTION,
3008
3029
  defaults: LITERAL_DEFAULTS,
@@ -3990,9 +4011,8 @@ function nonEmptyString(value) {
3990
4011
  // src/domains/worktree/occupancy-store.ts
3991
4012
  import { join as join6 } from "path";
3992
4013
  var OCCUPANCY_STATUS = {
3993
- UNCLAIMED: "unclaimed",
3994
- OCCUPIED: "occupied",
3995
- STALE: "stale"
4014
+ FREE: "free",
4015
+ RUNNING: "running"
3996
4016
  };
3997
4017
  var OCCUPANCY_CLAIM = {
3998
4018
  FILE_EXTENSION: ".claim",
@@ -4022,13 +4042,13 @@ function claimTempFilePath(claimPath, writeToken) {
4022
4042
  return { ok: true, value: `${claimPath}.${validated.value}${OCCUPANCY_CLAIM.TEMP_EXTENSION}` };
4023
4043
  }
4024
4044
  function classifyOccupancy(claim, probe) {
4025
- if (claim === void 0) return OCCUPANCY_STATUS.UNCLAIMED;
4026
- if (claim.host !== probe.currentHost()) return OCCUPANCY_STATUS.STALE;
4027
- if (!probe.isAlive(claim.pid)) return OCCUPANCY_STATUS.STALE;
4045
+ if (claim === void 0) return OCCUPANCY_STATUS.FREE;
4046
+ if (claim.host !== probe.currentHost()) return OCCUPANCY_STATUS.FREE;
4047
+ if (!probe.isAlive(claim.pid)) return OCCUPANCY_STATUS.FREE;
4048
+ if (claim.startedAt === unreadableStartedAt(claim.pid)) return OCCUPANCY_STATUS.RUNNING;
4028
4049
  const liveStartTime = probe.startTimeOf(claim.pid);
4029
- if (claim.startedAt === unreadableStartedAt(claim.pid)) return OCCUPANCY_STATUS.OCCUPIED;
4030
- if (liveStartTime !== void 0 && liveStartTime !== claim.startedAt) return OCCUPANCY_STATUS.STALE;
4031
- return OCCUPANCY_STATUS.OCCUPIED;
4050
+ if (liveStartTime !== void 0 && liveStartTime !== claim.startedAt) return OCCUPANCY_STATUS.FREE;
4051
+ return OCCUPANCY_STATUS.RUNNING;
4032
4052
  }
4033
4053
  function unreadableStartedAt(pid) {
4034
4054
  return `${OCCUPANCY_CLAIM.UNREADABLE_STARTED_AT_PREFIX}${pid}`;
@@ -4070,11 +4090,6 @@ async function removeClaim(worktreesDir, name, options) {
4070
4090
  return { ok: false, error: formatOccupancyError(OCCUPANCY_ERROR.CLAIM_REMOVE_FAILED, toErrorMessage2(error)) };
4071
4091
  }
4072
4092
  }
4073
- async function readOccupancy(worktreesDir, name, probe, options) {
4074
- const claimResult = await readClaim(worktreesDir, name, options);
4075
- if (!claimResult.ok) return claimResult;
4076
- return { ok: true, value: classifyOccupancy(claimResult.value, probe) };
4077
- }
4078
4093
  function serializeClaim(record6) {
4079
4094
  return JSON.stringify({
4080
4095
  sessionId: record6.sessionId,
@@ -4102,7 +4117,10 @@ function formatOccupancyError(code, detail) {
4102
4117
  return `${code}${ERROR_DETAIL_SEPARATOR2}${detail}`;
4103
4118
  }
4104
4119
  function toErrorMessage2(error) {
4105
- return error instanceof Error ? error.message : String(error);
4120
+ if (error instanceof Error) return error.message;
4121
+ if (typeof error === "string") return error;
4122
+ if (typeof error === "number" || typeof error === "bigint" || typeof error === "boolean") return error.toString();
4123
+ return JSON.stringify(error) ?? "unknown error";
4106
4124
  }
4107
4125
 
4108
4126
  // src/domains/worktree/worktree-name.ts
@@ -4252,27 +4270,25 @@ var defaultWorktreePoolProbe = {
4252
4270
  errored: true,
4253
4271
  bareRepository: false,
4254
4272
  linkedWorktrees: false,
4255
- staleClaim: false
4273
+ running: 0,
4274
+ free: 0
4256
4275
  };
4276
+ const root = await detectGitCommonDirProductRoot();
4257
4277
  const facts = await gatherGitFacts();
4258
- if (!facts?.worktreeListRead) return errored;
4278
+ if (!root.isGitRepo || !facts?.worktreeListRead) return errored;
4259
4279
  const bareRepository = facts.commonDirIsBare;
4260
4280
  const paths = facts.worktreeRoots;
4261
4281
  const linkedWorktrees = !bareRepository && paths.length > 1;
4262
- const spx = resolveSpx();
4263
- if (spx === null) return errored;
4264
- let staleClaim = false;
4282
+ const worktreesDir = worktreesScopeDir(root.productDir);
4283
+ let running = 0;
4284
+ let free = 0;
4265
4285
  for (const path7 of paths) {
4266
- const status = await runCapture(spx, ["worktree", "status", path7, "--format", "json"]);
4267
- if (!status.ok) return errored;
4268
- const occupancy = worktreeStatusOf(status.stdout);
4269
- if (occupancy === null) return errored;
4270
- if (occupancy === OCCUPANCY_STATUS.STALE) {
4271
- staleClaim = true;
4272
- break;
4273
- }
4286
+ const claim = await readClaim(worktreesDir, worktreeClaimName(path7), { fs: defaultOccupancyFileSystem });
4287
+ if (!claim.ok) return errored;
4288
+ if (classifyOccupancy(claim.value, defaultProcessTable) === OCCUPANCY_STATUS.RUNNING) running += 1;
4289
+ else free += 1;
4274
4290
  }
4275
- return { errored: false, bareRepository, linkedWorktrees, staleClaim };
4291
+ return { errored: false, bareRepository, linkedWorktrees, running, free };
4276
4292
  }
4277
4293
  };
4278
4294
  var defaultSessionEnvironmentProbe = {
@@ -4281,22 +4297,21 @@ var defaultSessionEnvironmentProbe = {
4281
4297
  const sessionIdentity = resolveAgentSessionId(process.env) !== void 0;
4282
4298
  const spx = resolveSpx();
4283
4299
  if (spx === null) {
4284
- return { errored: true, hookPresent, sessionIdentity, worktreeClaimed: false, roundTripStale: false };
4300
+ return { errored: true, hookPresent, sessionIdentity, worktreeClaimed: false };
4285
4301
  }
4286
4302
  const status = await runCapture(spx, ["worktree", "status", "--format", "json"]);
4287
4303
  if (!status.ok) {
4288
- return { errored: true, hookPresent, sessionIdentity, worktreeClaimed: false, roundTripStale: false };
4304
+ return { errored: true, hookPresent, sessionIdentity, worktreeClaimed: false };
4289
4305
  }
4290
4306
  const occupancy = worktreeStatusOf(status.stdout);
4291
4307
  if (occupancy === null) {
4292
- return { errored: true, hookPresent, sessionIdentity, worktreeClaimed: false, roundTripStale: false };
4308
+ return { errored: true, hookPresent, sessionIdentity, worktreeClaimed: false };
4293
4309
  }
4294
4310
  return {
4295
4311
  errored: false,
4296
4312
  hookPresent,
4297
4313
  sessionIdentity,
4298
- worktreeClaimed: occupancy === OCCUPANCY_STATUS.OCCUPIED,
4299
- roundTripStale: occupancy === OCCUPANCY_STATUS.STALE
4314
+ worktreeClaimed: occupancy === OCCUPANCY_STATUS.RUNNING
4300
4315
  };
4301
4316
  }
4302
4317
  };
@@ -4310,7 +4325,7 @@ async function claimedSessionIds() {
4310
4325
  for (const path7 of facts.worktreeRoots) {
4311
4326
  const claim = await readClaim(worktreesDir, worktreeClaimName(path7), { fs: defaultOccupancyFileSystem });
4312
4327
  if (!claim.ok || claim.value === void 0) continue;
4313
- if (classifyOccupancy(claim.value, defaultProcessTable) === OCCUPANCY_STATUS.OCCUPIED) {
4328
+ if (classifyOccupancy(claim.value, defaultProcessTable) === OCCUPANCY_STATUS.RUNNING) {
4314
4329
  sessionIds.add(normalizeAgentSessionToken(claim.value.sessionId));
4315
4330
  }
4316
4331
  }
@@ -4525,8 +4540,7 @@ function record2(verdict, bucket, reading) {
4525
4540
  readings: {
4526
4541
  hook: String(reading.hookPresent),
4527
4542
  identity: String(reading.sessionIdentity),
4528
- claimed: String(reading.worktreeClaimed),
4529
- stale: String(reading.roundTripStale)
4543
+ claimed: String(reading.worktreeClaimed)
4530
4544
  },
4531
4545
  remediation: REMEDIATION2[verdict]
4532
4546
  };
@@ -4535,7 +4549,7 @@ function classifySessionEnvironment(reading) {
4535
4549
  if (!reading.hookPresent) {
4536
4550
  return record2(SESSION_ENVIRONMENT_VERDICT.NOT_APPLICABLE, VERDICT_BUCKET.NOT_APPLICABLE, reading);
4537
4551
  }
4538
- if (reading.errored || reading.roundTripStale) {
4552
+ if (reading.errored) {
4539
4553
  return record2(SESSION_ENVIRONMENT_VERDICT.UNKNOWN, VERDICT_BUCKET.UNKNOWN, reading);
4540
4554
  }
4541
4555
  if (reading.sessionIdentity && reading.worktreeClaimed) {
@@ -4561,7 +4575,7 @@ var SESSION_STORE_VERDICT = {
4561
4575
  };
4562
4576
  var REMEDIATION3 = {
4563
4577
  [SESSION_STORE_VERDICT.CONSISTENT]: "Session store is consistent; no action needed.",
4564
- [SESSION_STORE_VERDICT.ORPHANED_CLAIMS]: "Release or reclaim doing sessions whose backing worktree is stale or absent (spx session release).",
4578
+ [SESSION_STORE_VERDICT.ORPHANED_CLAIMS]: "Release or reclaim doing sessions whose backing worktree reads free or is absent (spx session release).",
4565
4579
  [SESSION_STORE_VERDICT.UNKNOWN]: "Re-run diagnose; if it persists, inspect spx session list and spx worktree status."
4566
4580
  };
4567
4581
  function record3(verdict, bucket, reading) {
@@ -4686,13 +4700,11 @@ function spxReachabilityRunner(probe) {
4686
4700
  // src/domains/diagnose/checks/worktree-pool.ts
4687
4701
  var WORKTREE_POOL_VERDICT = {
4688
4702
  COMPLIANT: "compliant",
4689
- STALE_CLAIMS: "stale-claims",
4690
4703
  NON_COMPLIANT: "non-compliant",
4691
4704
  UNKNOWN: "unknown"
4692
4705
  };
4693
4706
  var REMEDIATION5 = {
4694
4707
  [WORKTREE_POOL_VERDICT.COMPLIANT]: "Worktree layout is compliant; no action needed.",
4695
- [WORKTREE_POOL_VERDICT.STALE_CLAIMS]: "Release stale worktree claims (spx worktree release) or remove dead worktrees.",
4696
4708
  [WORKTREE_POOL_VERDICT.NON_COMPLIANT]: "Linked worktrees require a bare-repository pool; convert the layout or remove the linked worktrees.",
4697
4709
  [WORKTREE_POOL_VERDICT.UNKNOWN]: "Re-run diagnose; if it persists, inspect git worktree list and spx worktree status."
4698
4710
  };
@@ -4704,7 +4716,8 @@ function record5(verdict, bucket, reading) {
4704
4716
  readings: {
4705
4717
  bare: String(reading.bareRepository),
4706
4718
  linked: String(reading.linkedWorktrees),
4707
- stale: String(reading.staleClaim)
4719
+ running: String(reading.running),
4720
+ free: String(reading.free)
4708
4721
  },
4709
4722
  remediation: REMEDIATION5[verdict]
4710
4723
  };
@@ -4716,9 +4729,6 @@ function classifyWorktreePool(reading) {
4716
4729
  if (!reading.bareRepository && reading.linkedWorktrees) {
4717
4730
  return record5(WORKTREE_POOL_VERDICT.NON_COMPLIANT, VERDICT_BUCKET.BROKEN, reading);
4718
4731
  }
4719
- if (reading.staleClaim) {
4720
- return record5(WORKTREE_POOL_VERDICT.STALE_CLAIMS, VERDICT_BUCKET.DEGRADED, reading);
4721
- }
4722
4732
  return record5(WORKTREE_POOL_VERDICT.COMPLIANT, VERDICT_BUCKET.HEALTHY, reading);
4723
4733
  }
4724
4734
  function worktreePoolRunner(probe) {
@@ -4736,7 +4746,7 @@ var DEL_CHAR_CODE = 127;
4736
4746
  var HEX_RADIX = 16;
4737
4747
  var HEX_PAD = 2;
4738
4748
  function formatHexEscape(code) {
4739
- return `\\x${code.toString(HEX_RADIX).padStart(HEX_PAD, "0")}`;
4749
+ return String.raw`\x${code.toString(HEX_RADIX).padStart(HEX_PAD, "0")}`;
4740
4750
  }
4741
4751
  function nonStringSentinel(type) {
4742
4752
  return `<non-string:${type}>`;
@@ -4824,7 +4834,7 @@ import { appendFile as nodeAppendFile } from "fs/promises";
4824
4834
  // src/domains/worktree/controlling-process.ts
4825
4835
  var CONTROLLING_PID_ENV = "SPX_WORKTREE_CONTROLLING_PID";
4826
4836
  var AGENT_RUNTIME_NAMES = ["claude", "codex"];
4827
- var AGENT_COMMAND_PATTERN = new RegExp(`\\b(?:${AGENT_RUNTIME_NAMES.join("|")})\\b`, "i");
4837
+ var AGENT_COMMAND_PATTERN = new RegExp(String.raw`\b(?:${AGENT_RUNTIME_NAMES.join("|")})\b`, "i");
4828
4838
  var CONTROLLING_PROCESS_ERROR = {
4829
4839
  UNRESOLVED: "worktree controlling process could not be resolved"
4830
4840
  };
@@ -6300,9 +6310,7 @@ var NoSessionsAvailableError = class extends SessionError {
6300
6310
  var SessionLegacyFrontmatterInputError = class extends SessionError {
6301
6311
  constructor() {
6302
6312
  super(
6303
- `Invalid handoff input: stdin opens with the YAML-frontmatter delimiter \`---\`. Use the JSON-prefix wire format: a single JSON object holding caller-supplied fields followed by the body bytes verbatim. Example:
6304
-
6305
- printf '%s\\n' '{"priority":"high","goal":"...","next_step":"..."}' '# Body' | spx session handoff`
6313
+ "Invalid handoff input: stdin opens with the YAML-frontmatter delimiter `---`. Use the JSON-prefix wire format: a single JSON object holding caller-supplied fields followed by the body bytes verbatim. Example:\n\n" + String.raw` printf '%s\n' '{"priority":"high","goal":"...","next_step":"..."}' '# Body' ` + "| spx session handoff"
6306
6314
  );
6307
6315
  this.name = "SessionLegacyFrontmatterInputError";
6308
6316
  }
@@ -6725,7 +6733,7 @@ function resolveSessionPaths(id, config = DEFAULT_SESSION_CONFIG) {
6725
6733
  }
6726
6734
  function formatShowOutput(content, options) {
6727
6735
  const metadata = parseSessionMetadata(content);
6728
- const headerLines = [
6736
+ const headerLines2 = [
6729
6737
  `${SESSION_SHOW_LABEL.STATUS}: ${options.status}`,
6730
6738
  `${SESSION_SHOW_LABEL.PRIORITY}: ${metadata.priority}`,
6731
6739
  `${SESSION_SHOW_LABEL.GIT_REF}: ${metadata.git_ref}`,
@@ -6734,7 +6742,7 @@ function formatShowOutput(content, options) {
6734
6742
  `${SESSION_SHOW_LABEL.CREATED}: ${metadata.created_at ?? ""}`,
6735
6743
  `${SESSION_SHOW_LABEL.AGENT_SESSION}: ${metadata.agent_session_id ?? ""}`
6736
6744
  ];
6737
- const header = headerLines.join("\n");
6745
+ const header = headerLines2.join("\n");
6738
6746
  const separator = "\n" + SESSION_SHOW_SEPARATOR_CHAR.repeat(SESSION_SHOW_SEPARATOR_WIDTH) + "\n\n";
6739
6747
  return header + separator + content;
6740
6748
  }
@@ -7494,13 +7502,13 @@ Workflow:
7494
7502
  4. archive - Move session to archive
7495
7503
  5. delete - Remove session permanently
7496
7504
  `;
7497
- var HANDOFF_FRONTMATTER_HELP = `
7505
+ var HANDOFF_FRONTMATTER_HELP = String.raw`
7498
7506
  Usage:
7499
7507
  Pipe a JSON header followed by the body bytes to stdin.
7500
7508
 
7501
7509
  The header is a single JSON object holding caller-supplied structured fields.
7502
7510
  A single LF or CRLF after the header is consumed as a separator. The body
7503
- is the remaining bytes verbatim \u2014 no YAML, no escape rules, no ambiguity
7511
+ is the remaining bytes verbatim no YAML, no escape rules, no ambiguity
7504
7512
  from leading characters like '#' or '---'.
7505
7513
 
7506
7514
  JSON Header Fields:
@@ -7523,11 +7531,11 @@ Output Tags (for automation):
7523
7531
  <SESSION_FILE>/path/to/file</SESSION_FILE> Absolute path to created file
7524
7532
 
7525
7533
  Canonical Invocation:
7526
- printf '%s\\n' \\
7527
- '{"priority":"high","goal":"Fix login","next_step":"Run validation","specs":[],"files":[]}' \\
7528
- '# Fix login' \\
7529
- '' \\
7530
- 'Body text \u2014 # symbols, --- delimiters, and code fences are literal.' \\
7534
+ printf '%s\n' \
7535
+ '{"priority":"high","goal":"Fix login","next_step":"Run validation","specs":[],"files":[]}' \
7536
+ '# Fix login' \
7537
+ '' \
7538
+ 'Body text # symbols, --- delimiters, and code fences are literal.' \
7531
7539
  | spx session handoff
7532
7540
  `;
7533
7541
  var PICKUP_SELECTION_HELP = `
@@ -9224,92 +9232,111 @@ async function runNodeCommand(options, deps) {
9224
9232
  return { dispatch, runFile, recorded };
9225
9233
  }
9226
9234
 
9227
- // src/commands/spec/node-outcome-resolver.ts
9228
- var PATH_SEPARATOR = "/";
9229
- function createNodeOutcomeResolver(deps) {
9230
- let evidence;
9231
- const currentInputsByCoveredPaths = /* @__PURE__ */ new Map();
9232
- const sharedEvidence = () => evidence ??= loadResolverEvidence(deps);
9233
- const refreshEvidence = () => {
9234
- evidence = loadResolverEvidence(deps);
9235
- };
9236
- const currentInputsFor = (coveredPaths) => {
9237
- const cacheKey = coveredPathCollectionKey(coveredPaths);
9238
- const cached = currentInputsByCoveredPaths.get(cacheKey);
9239
- if (cached !== void 0) {
9240
- return cached;
9241
- }
9242
- const current = currentStalenessInputs(deps.productDir, coveredPaths, deps);
9243
- currentInputsByCoveredPaths.set(cacheKey, current);
9244
- return current;
9245
- };
9246
- return async (nodeId) => {
9247
- const { discoveredTestPaths, terminalRuns } = await sharedEvidence();
9248
- const nodePath = `${SPEC_TREE_CONFIG.ROOT_DIRECTORY}${PATH_SEPARATOR}${nodeId}`;
9249
- const nodeTestPaths = filterNodeTestPaths(discoveredTestPaths, nodePath);
9250
- const usable = await usableRecordedOutcome(discoveredTestPaths, terminalRuns, nodeTestPaths, currentInputsFor);
9251
- if (usable !== void 0) {
9252
- return usable;
9253
- }
9254
- const { dispatch, recorded } = await runNodeCommand({ productDir: deps.productDir, nodePath }, deps);
9255
- refreshEvidence();
9256
- return outcomesCoverPaths(dispatch.outcomes, nodeTestPaths) && recorded.status === TEST_RUN_STATE_STATUS.PASSED;
9257
- };
9258
- }
9259
- function coveredPathCollectionKey(coveredPaths) {
9260
- return JSON.stringify([...coveredPaths].sort(compareAsciiStrings));
9261
- }
9262
- async function loadResolverEvidence(deps) {
9263
- const discoveredTestPaths = await discoverTestFiles(deps.productDir);
9264
- const runs = await readTestingRuns(deps.productDir, deps);
9265
- return { discoveredTestPaths, terminalRuns: runs.ok ? runs.value.terminalRuns : [] };
9266
- }
9267
- function filterNodeTestPaths(discoveredTestPaths, nodePath) {
9268
- const prefix = `${nodePath}${PATH_SEPARATOR}`;
9269
- return discoveredTestPaths.filter((path7) => path7.startsWith(prefix));
9270
- }
9271
- async function usableRecordedOutcome(discoveredTestPaths, terminalRuns, nodeTestPaths, currentInputsFor) {
9272
- const latest = selectLatestTerminalTestRunForNode(terminalRuns, nodeTestPaths);
9273
- if (latest === void 0) {
9274
- return void 0;
9275
- }
9276
- const runCoveredPaths = latest.state.runnerOutcomes.flatMap((outcome) => outcome.testPaths);
9277
- const presentTestPaths = new Set(discoveredTestPaths);
9278
- if (!runCoveredPaths.every((path7) => presentTestPaths.has(path7))) {
9279
- return void 0;
9280
- }
9281
- const current = await currentInputsFor(runCoveredPaths);
9282
- const fresh = isStalenessMatch(extractStalenessInputs(latest.state), current);
9283
- return fresh && latest.state.status === TEST_RUN_STATE_STATUS.PASSED ? true : void 0;
9284
- }
9235
+ // src/lib/node-status/ci-projection.ts
9236
+ import { parse as parseYaml3 } from "yaml";
9285
9237
 
9286
9238
  // src/lib/node-status/classify.ts
9287
- var NODE_STATUS_STATUS_KEY = "status";
9239
+ var NODE_STATUS_SCHEMA_VERSION = 1;
9288
9240
  var NODE_STATUS_JSON_INDENT = 2;
9241
+ var NODE_STATUS_FIELD = {
9242
+ SCHEMA_VERSION: "schemaVersion",
9243
+ VERIFICATION: "verification",
9244
+ OVERALL: "overall"
9245
+ };
9246
+ var NODE_STATUS_VERIFICATION_MECHANISM = {
9247
+ TEST: "test",
9248
+ EVAL: "eval",
9249
+ AUDIT: "audit"
9250
+ };
9251
+ var NODE_STATUS_EVIDENCE_OUTCOME = {
9252
+ PASSED: "passed",
9253
+ FAILED: "failed",
9254
+ NOT_RUN: "not-run"
9255
+ };
9256
+ var NODE_STATUS_MECHANISM_OVERALL = {
9257
+ PASSED: NODE_STATUS_EVIDENCE_OUTCOME.PASSED,
9258
+ FAILED: NODE_STATUS_EVIDENCE_OUTCOME.FAILED,
9259
+ PARTIAL: "partial",
9260
+ NOT_RUN: NODE_STATUS_EVIDENCE_OUTCOME.NOT_RUN
9261
+ };
9289
9262
  function classifyNodeStatus(facts) {
9290
- if (!facts.hasTests) return SPEC_TREE_NODE_STATE.DECLARED;
9263
+ if (!facts.hasVerificationReferences) return SPEC_TREE_NODE_STATE.DECLARED;
9291
9264
  if (facts.isExcluded) return SPEC_TREE_NODE_STATE.SPECIFIED;
9292
- if (facts.testsPass) return SPEC_TREE_NODE_STATE.PASSING;
9265
+ if (verificationPassed(facts.verification)) return SPEC_TREE_NODE_STATE.PASSING;
9293
9266
  return SPEC_TREE_NODE_STATE.FAILING;
9294
9267
  }
9295
- function serializeNodeStatus(state) {
9296
- return `${JSON.stringify({ [NODE_STATUS_STATUS_KEY]: state }, null, NODE_STATUS_JSON_INDENT)}
9268
+ function createNodeStatusFile(verification) {
9269
+ return {
9270
+ [NODE_STATUS_FIELD.SCHEMA_VERSION]: NODE_STATUS_SCHEMA_VERSION,
9271
+ [NODE_STATUS_FIELD.VERIFICATION]: verification
9272
+ };
9273
+ }
9274
+ function hasNodeStatusVerificationReferences(verification) {
9275
+ return Object.values(verification).some(
9276
+ (mechanism) => Object.keys(mechanism).some((reference) => reference !== NODE_STATUS_FIELD.OVERALL)
9277
+ );
9278
+ }
9279
+ function createNodeStatusMechanismRecord(outcomes) {
9280
+ return {
9281
+ [NODE_STATUS_FIELD.OVERALL]: rollupNodeStatusMechanism(outcomes),
9282
+ ...outcomes
9283
+ };
9284
+ }
9285
+ function rollupNodeStatusMechanism(outcomes) {
9286
+ const values = Object.values(outcomes);
9287
+ if (values.length === 0) return NODE_STATUS_MECHANISM_OVERALL.NOT_RUN;
9288
+ if (values.includes(NODE_STATUS_EVIDENCE_OUTCOME.FAILED)) {
9289
+ return NODE_STATUS_MECHANISM_OVERALL.FAILED;
9290
+ }
9291
+ if (values.every((outcome) => outcome === NODE_STATUS_EVIDENCE_OUTCOME.PASSED)) {
9292
+ return NODE_STATUS_MECHANISM_OVERALL.PASSED;
9293
+ }
9294
+ if (values.every((outcome) => outcome === NODE_STATUS_EVIDENCE_OUTCOME.NOT_RUN)) {
9295
+ return NODE_STATUS_MECHANISM_OVERALL.NOT_RUN;
9296
+ }
9297
+ return NODE_STATUS_MECHANISM_OVERALL.PARTIAL;
9298
+ }
9299
+ function serializeNodeStatus(status) {
9300
+ return `${JSON.stringify(status, null, NODE_STATUS_JSON_INDENT)}
9297
9301
  `;
9298
9302
  }
9303
+ function verificationPassed(verification) {
9304
+ if (verification === void 0) return false;
9305
+ const mechanisms = Object.values(verification);
9306
+ return mechanisms.length > 0 && mechanisms.every((mechanism) => mechanism[NODE_STATUS_FIELD.OVERALL] === NODE_STATUS_MECHANISM_OVERALL.PASSED);
9307
+ }
9299
9308
 
9300
9309
  // src/lib/node-status/provider.ts
9301
9310
  import { join as join20 } from "path";
9302
9311
 
9312
+ // src/lib/node-status/exclude.ts
9313
+ function isNodeStatusEntryExcluded(ignoreReader, node) {
9314
+ const reference = node.ref?.path;
9315
+ if (reference === void 0) return false;
9316
+ return ignoreReader.isUnderIgnoreSource(reference);
9317
+ }
9318
+
9303
9319
  // src/lib/node-status/read.ts
9304
9320
  import { readFileSync as readFileSync2 } from "fs";
9305
9321
  import { join as join19 } from "path";
9306
9322
  var NODE_STATUS_FILENAME = "spx.status.json";
9307
- var NODE_STATUS_VALUES = new Set(Object.values(SPEC_TREE_NODE_STATE));
9323
+ var NODE_STATUS_MECHANISMS = new Set(Object.values(NODE_STATUS_VERIFICATION_MECHANISM));
9324
+ var NODE_STATUS_EVIDENCE_OUTCOMES = new Set(Object.values(NODE_STATUS_EVIDENCE_OUTCOME));
9325
+ var NODE_STATUS_OVERALL_VALUES = new Set(Object.values(NODE_STATUS_MECHANISM_OVERALL));
9308
9326
  function isNodeError2(error) {
9309
9327
  return error instanceof Error && "code" in error;
9310
9328
  }
9311
- function isSpecTreeNodeState(value) {
9312
- return typeof value === "string" && NODE_STATUS_VALUES.has(value);
9329
+ function isObject(value) {
9330
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9331
+ }
9332
+ function isNodeStatusMechanism(value) {
9333
+ return NODE_STATUS_MECHANISMS.has(value);
9334
+ }
9335
+ function isNodeStatusEvidenceOutcome(value) {
9336
+ return typeof value === "string" && NODE_STATUS_EVIDENCE_OUTCOMES.has(value);
9337
+ }
9338
+ function isNodeStatusMechanismOverall(value) {
9339
+ return typeof value === "string" && NODE_STATUS_OVERALL_VALUES.has(value);
9313
9340
  }
9314
9341
  function readNodeStatus(nodeDir) {
9315
9342
  const filePath = join19(nodeDir, NODE_STATUS_FILENAME);
@@ -9322,14 +9349,67 @@ function readNodeStatus(nodeDir) {
9322
9349
  }
9323
9350
  throw error;
9324
9351
  }
9325
- const parsed = JSON.parse(content);
9326
- const status = parsed[NODE_STATUS_STATUS_KEY];
9327
- if (!isSpecTreeNodeState(status)) {
9352
+ return parseNodeStatusFile(JSON.parse(content), filePath);
9353
+ }
9354
+ function parseNodeStatusFile(candidate, source) {
9355
+ if (!isObject(candidate)) {
9356
+ throw new Error(`Invalid ${NODE_STATUS_FILENAME} at ${source}: expected a JSON object`);
9357
+ }
9358
+ if (candidate[NODE_STATUS_FIELD.SCHEMA_VERSION] !== NODE_STATUS_SCHEMA_VERSION) {
9359
+ throw new Error(
9360
+ `Invalid ${NODE_STATUS_FILENAME} at ${source}: schemaVersion must be ${NODE_STATUS_SCHEMA_VERSION}`
9361
+ );
9362
+ }
9363
+ const verification = parseVerification(candidate[NODE_STATUS_FIELD.VERIFICATION], source);
9364
+ return {
9365
+ [NODE_STATUS_FIELD.SCHEMA_VERSION]: NODE_STATUS_SCHEMA_VERSION,
9366
+ [NODE_STATUS_FIELD.VERIFICATION]: verification
9367
+ };
9368
+ }
9369
+ function parseVerification(candidate, source) {
9370
+ if (!isObject(candidate)) {
9371
+ throw new Error(`Invalid ${NODE_STATUS_FILENAME} at ${source}: verification must be an object`);
9372
+ }
9373
+ const verification = {};
9374
+ for (const [mechanism, rawRecord] of Object.entries(candidate)) {
9375
+ if (!isNodeStatusMechanism(mechanism)) {
9376
+ throw new Error(`Invalid ${NODE_STATUS_FILENAME} at ${source}: unknown verification mechanism "${mechanism}"`);
9377
+ }
9378
+ verification[mechanism] = parseMechanismRecord(rawRecord, source, mechanism);
9379
+ }
9380
+ return verification;
9381
+ }
9382
+ function parseMechanismRecord(candidate, source, mechanism) {
9383
+ if (!isObject(candidate)) {
9384
+ throw new Error(`Invalid ${NODE_STATUS_FILENAME} at ${source}: verification.${mechanism} must be an object`);
9385
+ }
9386
+ const overall = candidate[NODE_STATUS_FIELD.OVERALL];
9387
+ if (!isNodeStatusMechanismOverall(overall)) {
9388
+ throw new Error(
9389
+ `Invalid ${NODE_STATUS_FILENAME} at ${source}: verification.${mechanism}.overall is invalid`
9390
+ );
9391
+ }
9392
+ const parsed = {
9393
+ [NODE_STATUS_FIELD.OVERALL]: overall
9394
+ };
9395
+ const outcomes = {};
9396
+ for (const [reference, outcome] of Object.entries(candidate)) {
9397
+ if (reference === NODE_STATUS_FIELD.OVERALL) continue;
9398
+ if (!isNodeStatusEvidenceOutcome(outcome)) {
9399
+ throw new Error(
9400
+ `Invalid ${NODE_STATUS_FILENAME} at ${source}: verification.${mechanism}.${reference} is invalid`
9401
+ );
9402
+ }
9403
+ outcomes[reference] = outcome;
9404
+ parsed[reference] = outcome;
9405
+ }
9406
+ const expectedOverall = rollupNodeStatusMechanism(outcomes);
9407
+ if (overall !== expectedOverall) {
9328
9408
  throw new Error(
9329
- `Invalid ${NODE_STATUS_FILENAME} at ${filePath}: status "${String(status)}" is not a lifecycle state`
9409
+ `Invalid ${NODE_STATUS_FILENAME} at ${source}: verification.${mechanism}.overall does not match evidence outcomes`
9330
9410
  );
9331
9411
  }
9332
- return status;
9412
+ return parsed;
9333
9413
  }
9334
9414
 
9335
9415
  // src/lib/node-status/provider.ts
@@ -9337,15 +9417,25 @@ function nodeDirectory(productDir, node) {
9337
9417
  return join20(productDir, SPEC_TREE_CONFIG.ROOT_DIRECTORY, node.id);
9338
9418
  }
9339
9419
  function createNodeStatusProvider(productDir) {
9420
+ const ignoreReader = createIgnoreSourceReader(productDir, {
9421
+ ignoreSourceFilename: IGNORE_SOURCE_FILENAME_DEFAULT,
9422
+ specTreeRootSegment: SPEC_TREE_CONFIG.ROOT_DIRECTORY
9423
+ });
9340
9424
  return {
9341
9425
  stateForNode(node) {
9342
- return readNodeStatus(nodeDirectory(productDir, node));
9426
+ const status = readNodeStatus(nodeDirectory(productDir, node));
9427
+ if (status === void 0) return void 0;
9428
+ return classifyNodeStatus({
9429
+ hasVerificationReferences: hasNodeStatusVerificationReferences(status.verification),
9430
+ isExcluded: isNodeStatusEntryExcluded(ignoreReader, node),
9431
+ verification: status.verification
9432
+ });
9343
9433
  }
9344
9434
  };
9345
9435
  }
9346
9436
 
9347
9437
  // src/lib/node-status/update.ts
9348
- import { mkdir as mkdir4, writeFile as writeFile2 } from "fs/promises";
9438
+ import { mkdir as mkdir4, readdir as readdir7, rm, writeFile as writeFile2 } from "fs/promises";
9349
9439
  import { dirname as dirname6, join as join21 } from "path";
9350
9440
  var NODE_STATUS_TEXT_ENCODING = "utf8";
9351
9441
  async function updateNodeStatus(options) {
@@ -9355,38 +9445,166 @@ async function updateNodeStatus(options) {
9355
9445
  ignoreSourceFilename: IGNORE_SOURCE_FILENAME_DEFAULT,
9356
9446
  specTreeRootSegment: SPEC_TREE_CONFIG.ROOT_DIRECTORY
9357
9447
  });
9358
- const nodesWithTests = collectNodesWithTests(snapshot);
9448
+ const evidenceByNode = collectEvidenceByNode(snapshot);
9449
+ const liveStatusPaths = /* @__PURE__ */ new Set();
9359
9450
  for (const node of snapshot.allNodes) {
9360
- const facts = await classifyFacts(node, {
9361
- hasTests: nodesWithTests.has(node.id),
9362
- isExcluded: isNodeExcluded(ignoreReader, node),
9451
+ const evidence = evidenceByNode.get(node.id) ?? [];
9452
+ const verification = await resolveVerification(node, {
9453
+ evidence,
9454
+ isExcluded: isNodeStatusEntryExcluded(ignoreReader, node),
9363
9455
  resolveOutcome
9364
9456
  });
9365
- await writeNodeStatus(productDir, node.id, classifyNodeStatus(facts));
9366
- }
9367
- }
9368
- async function classifyFacts(node, input) {
9369
- const testsPass = input.hasTests && !input.isExcluded ? await input.resolveOutcome(node.id) : false;
9370
- return { hasTests: input.hasTests, isExcluded: input.isExcluded, testsPass };
9457
+ const statusPath = nodeStatusPath(productDir, node.id);
9458
+ liveStatusPaths.add(statusPath);
9459
+ await writeNodeStatus(statusPath, verification);
9460
+ }
9461
+ await removeStaleNodeStatusFiles(productDir, liveStatusPaths);
9462
+ }
9463
+ async function resolveVerification(node, input) {
9464
+ if (input.evidence.length === 0) return {};
9465
+ if (input.isExcluded) return createTestVerification(input.evidence, NODE_STATUS_EVIDENCE_OUTCOME.NOT_RUN);
9466
+ return createTestVerificationFromOutcomes(
9467
+ input.evidence,
9468
+ await input.resolveOutcome(node.id, evidencePaths(input.evidence))
9469
+ );
9371
9470
  }
9372
- function collectNodesWithTests(snapshot) {
9373
- const parents = /* @__PURE__ */ new Set();
9471
+ function collectEvidenceByNode(snapshot) {
9472
+ const evidenceByNode = /* @__PURE__ */ new Map();
9374
9473
  for (const entry of snapshot.entries) {
9375
9474
  if (entry.type === SPEC_TREE_ENTRY_TYPE.EVIDENCE) {
9376
- parents.add(entry.parentId);
9475
+ const entries = evidenceByNode.get(entry.parentId) ?? [];
9476
+ entries.push(entry);
9477
+ evidenceByNode.set(entry.parentId, entries);
9377
9478
  }
9378
9479
  }
9379
- return parents;
9480
+ return evidenceByNode;
9380
9481
  }
9381
- function isNodeExcluded(ignoreReader, node) {
9382
- const reference = node.ref?.path;
9383
- if (reference === void 0) return false;
9384
- return ignoreReader.isUnderIgnoreSource(reference);
9482
+ function createTestVerification(evidence, outcome) {
9483
+ const outcomes = Object.fromEntries(evidence.map((entry) => [evidencePath(entry), outcome]));
9484
+ return createTestVerificationFromOutcomes(evidence, outcomes);
9385
9485
  }
9386
- async function writeNodeStatus(productDir, nodeId, state) {
9387
- const filePath = join21(productDir, SPEC_TREE_CONFIG.ROOT_DIRECTORY, nodeId, NODE_STATUS_FILENAME);
9486
+ function createTestVerificationFromOutcomes(evidence, resolvedOutcomes) {
9487
+ const outcomes = {};
9488
+ for (const entry of evidence) {
9489
+ const path7 = evidencePath(entry);
9490
+ outcomes[path7] = resolvedOutcomes[path7] ?? NODE_STATUS_EVIDENCE_OUTCOME.NOT_RUN;
9491
+ }
9492
+ return { [NODE_STATUS_VERIFICATION_MECHANISM.TEST]: createNodeStatusMechanismRecord(outcomes) };
9493
+ }
9494
+ function evidencePaths(evidence) {
9495
+ return evidence.map(evidencePath);
9496
+ }
9497
+ function evidencePath(entry) {
9498
+ return entry.ref?.path ?? entry.id;
9499
+ }
9500
+ async function writeNodeStatus(filePath, verification) {
9388
9501
  await mkdir4(dirname6(filePath), { recursive: true });
9389
- await writeFile2(filePath, serializeNodeStatus(state), NODE_STATUS_TEXT_ENCODING);
9502
+ await writeFile2(filePath, serializeNodeStatus(createNodeStatusFile(verification)), NODE_STATUS_TEXT_ENCODING);
9503
+ }
9504
+ function nodeStatusPath(productDir, nodeId) {
9505
+ return join21(productDir, SPEC_TREE_CONFIG.ROOT_DIRECTORY, nodeId, NODE_STATUS_FILENAME);
9506
+ }
9507
+ async function removeStaleNodeStatusFiles(productDir, liveStatusPaths) {
9508
+ const statusPaths = await collectNodeStatusFiles(join21(productDir, SPEC_TREE_CONFIG.ROOT_DIRECTORY));
9509
+ await Promise.all(statusPaths.filter((path7) => !liveStatusPaths.has(path7)).map((path7) => rm(path7, { force: true })));
9510
+ }
9511
+ async function collectNodeStatusFiles(directory) {
9512
+ const entries = await readDirectoryEntries(directory);
9513
+ const statusPaths = [];
9514
+ for (const entry of entries) {
9515
+ const entryPath = join21(directory, entry.name);
9516
+ if (entry.isDirectory()) {
9517
+ statusPaths.push(...await collectNodeStatusFiles(entryPath));
9518
+ } else if (entry.isFile() && entry.name === NODE_STATUS_FILENAME) {
9519
+ statusPaths.push(entryPath);
9520
+ }
9521
+ }
9522
+ return statusPaths;
9523
+ }
9524
+ async function readDirectoryEntries(directory) {
9525
+ try {
9526
+ return await readdir7(directory, { withFileTypes: true });
9527
+ } catch (error) {
9528
+ if (isNodeError3(error) && error.code === "ENOENT") return [];
9529
+ throw error;
9530
+ }
9531
+ }
9532
+ function isNodeError3(error) {
9533
+ return error instanceof Error && "code" in error;
9534
+ }
9535
+
9536
+ // src/commands/spec/node-outcome-resolver.ts
9537
+ var PATH_SEPARATOR = "/";
9538
+ var SUCCESS_EXIT_CODE2 = 0;
9539
+ function createNodeOutcomeResolver(deps) {
9540
+ let evidence;
9541
+ const currentInputsByCoveredPaths = /* @__PURE__ */ new Map();
9542
+ const sharedEvidence = () => evidence ??= loadResolverEvidence(deps);
9543
+ const refreshEvidence = () => {
9544
+ evidence = loadResolverEvidence(deps);
9545
+ };
9546
+ const currentInputsFor = (coveredPaths) => {
9547
+ const cacheKey = coveredPathCollectionKey(coveredPaths);
9548
+ const cached = currentInputsByCoveredPaths.get(cacheKey);
9549
+ if (cached !== void 0) {
9550
+ return cached;
9551
+ }
9552
+ const current = currentStalenessInputs(deps.productDir, coveredPaths, deps);
9553
+ currentInputsByCoveredPaths.set(cacheKey, current);
9554
+ return current;
9555
+ };
9556
+ return async (nodeId, evidencePaths2) => {
9557
+ const { discoveredTestPaths, terminalRuns } = await sharedEvidence();
9558
+ const nodePath = `${SPEC_TREE_CONFIG.ROOT_DIRECTORY}${PATH_SEPARATOR}${nodeId}`;
9559
+ const nodeTestPaths = filterNodeTestPaths(discoveredTestPaths, nodePath, evidencePaths2);
9560
+ const usable = await usableRecordedOutcome(discoveredTestPaths, terminalRuns, nodeTestPaths, currentInputsFor);
9561
+ if (usable !== void 0) {
9562
+ return usable;
9563
+ }
9564
+ const { dispatch } = await runNodeCommand({ productDir: deps.productDir, nodePath }, deps);
9565
+ refreshEvidence();
9566
+ return outcomesForPaths(dispatch.outcomes, nodeTestPaths);
9567
+ };
9568
+ }
9569
+ function coveredPathCollectionKey(coveredPaths) {
9570
+ return JSON.stringify([...coveredPaths].sort(compareAsciiStrings));
9571
+ }
9572
+ async function loadResolverEvidence(deps) {
9573
+ const discoveredTestPaths = await discoverTestFiles(deps.productDir);
9574
+ const runs = await readTestingRuns(deps.productDir, deps);
9575
+ return { discoveredTestPaths, terminalRuns: runs.ok ? runs.value.terminalRuns : [] };
9576
+ }
9577
+ function filterNodeTestPaths(discoveredTestPaths, nodePath, evidencePaths2) {
9578
+ const prefix = `${nodePath}${PATH_SEPARATOR}`;
9579
+ const discovered = new Set(discoveredTestPaths.filter((path7) => path7.startsWith(prefix)));
9580
+ return evidencePaths2.filter((path7) => discovered.has(path7));
9581
+ }
9582
+ async function usableRecordedOutcome(discoveredTestPaths, terminalRuns, nodeTestPaths, currentInputsFor) {
9583
+ const latest = selectLatestTerminalTestRunForNode(terminalRuns, nodeTestPaths);
9584
+ if (latest === void 0) {
9585
+ return void 0;
9586
+ }
9587
+ const runCoveredPaths = latest.state.runnerOutcomes.flatMap((outcome) => outcome.testPaths);
9588
+ const presentTestPaths = new Set(discoveredTestPaths);
9589
+ if (!runCoveredPaths.every((path7) => presentTestPaths.has(path7))) {
9590
+ return void 0;
9591
+ }
9592
+ const current = await currentInputsFor(runCoveredPaths);
9593
+ const fresh = isStalenessMatch(extractStalenessInputs(latest.state), current);
9594
+ return fresh && latest.state.status === TEST_RUN_STATE_STATUS.PASSED ? outcomesForPaths(latest.state.runnerOutcomes, nodeTestPaths) : void 0;
9595
+ }
9596
+ function outcomesForPaths(outcomes, nodeTestPaths) {
9597
+ return Object.fromEntries(
9598
+ nodeTestPaths.map((path7) => [path7, outcomeForPath(outcomes, path7)])
9599
+ );
9600
+ }
9601
+ function outcomeForPath(outcomes, path7) {
9602
+ const covering = outcomes.filter((outcome) => outcome.testPaths.includes(path7));
9603
+ if (covering.length === 0) return NODE_STATUS_EVIDENCE_OUTCOME.NOT_RUN;
9604
+ if (covering.every((outcome) => outcome.exitCode === SUCCESS_EXIT_CODE2)) {
9605
+ return NODE_STATUS_EVIDENCE_OUTCOME.PASSED;
9606
+ }
9607
+ return NODE_STATUS_EVIDENCE_OUTCOME.FAILED;
9390
9608
  }
9391
9609
 
9392
9610
  // src/commands/spec/status.ts
@@ -12516,7 +12734,7 @@ async function circularCommand(options, deps = defaultCircularCommandDeps) {
12516
12734
 
12517
12735
  // src/validation/steps/knip.ts
12518
12736
  import { existsSync as existsSync4 } from "fs";
12519
- import { mkdir as mkdir5, mkdtemp as mkdtemp2, rm, writeFile as writeFile3 } from "fs/promises";
12737
+ import { mkdir as mkdir5, mkdtemp as mkdtemp2, rm as rm2, writeFile as writeFile3 } from "fs/promises";
12520
12738
  import { isAbsolute as isAbsolute5, join as join28 } from "path";
12521
12739
  var defaultKnipProcessRunner = lifecycleProcessRunner;
12522
12740
  var KNIP_COMMAND_TOKENS = {
@@ -12529,7 +12747,7 @@ var defaultKnipDeps = {
12529
12747
  existsSync: existsSync4,
12530
12748
  mkdir: mkdir5,
12531
12749
  mkdtemp: mkdtemp2,
12532
- rm,
12750
+ rm: rm2,
12533
12751
  writeFile: writeFile3
12534
12752
  };
12535
12753
  async function validateKnip2(context, runner = defaultKnipProcessRunner, deps = defaultKnipDeps) {
@@ -13141,18 +13359,18 @@ var ignoreSourceLayer = makeLayer(
13141
13359
  );
13142
13360
 
13143
13361
  // src/lib/file-inclusion/pipeline.ts
13144
- import { readdir as readdir7 } from "fs/promises";
13362
+ import { readdir as readdir8 } from "fs/promises";
13145
13363
  import { join as join31, relative as relative6, sep as sep2 } from "path";
13146
13364
  var EXPLICIT_OVERRIDE_LAYER = "explicit-override";
13147
- function isNodeError3(err) {
13365
+ function isNodeError4(err) {
13148
13366
  return err instanceof Error && "code" in err;
13149
13367
  }
13150
13368
  async function collectPaths(absoluteDir, projectRoot, result, artifactDirs) {
13151
13369
  let dirEntries;
13152
13370
  try {
13153
- dirEntries = await readdir7(absoluteDir, { withFileTypes: true });
13371
+ dirEntries = await readdir8(absoluteDir, { withFileTypes: true });
13154
13372
  } catch (err) {
13155
- if (isNodeError3(err) && (err.code === "ENOENT" || err.code === "ENOTDIR")) {
13373
+ if (isNodeError4(err) && (err.code === "ENOENT" || err.code === "ENOTDIR")) {
13156
13374
  return;
13157
13375
  }
13158
13376
  throw err;
@@ -14588,6 +14806,10 @@ var WORKTREE_STATUS_FORMAT = {
14588
14806
  JSON: "json",
14589
14807
  TEXT: "text"
14590
14808
  };
14809
+ var WORKTREE_STATUS_RENDER = {
14810
+ FREE: "-",
14811
+ RUNNING_PID_PREFIX: "PID "
14812
+ };
14591
14813
  async function statusCommand2(options) {
14592
14814
  const multiTargetRequest = options.worktrees !== void 0 && options.worktrees.length > 1;
14593
14815
  const targets = await resolveStatusTargets(options);
@@ -14595,9 +14817,13 @@ async function statusCommand2(options) {
14595
14817
  const records = [];
14596
14818
  for (const target of targets.value) {
14597
14819
  const worktreesDir = await resolveWorktreesDir({ ...options, cwd: target.worktreeRoot });
14598
- const occupancy = await readOccupancy(worktreesDir, target.name, options.processTable, { fs: options.fs });
14599
- if (!occupancy.ok) return occupancy;
14600
- records.push({ worktree: target.name, status: occupancy.value });
14820
+ const claimResult = await readClaim(worktreesDir, target.name, { fs: options.fs });
14821
+ if (!claimResult.ok) return claimResult;
14822
+ const claim = claimResult.value;
14823
+ const status = classifyOccupancy(claim, options.processTable);
14824
+ records.push(
14825
+ status === OCCUPANCY_STATUS.RUNNING && claim !== void 0 ? { worktree: target.name, status, pid: claim.pid, session: claim.sessionId, host: claim.host } : { worktree: target.name, status }
14826
+ );
14601
14827
  }
14602
14828
  return { ok: true, value: renderStatus(records, options.format, multiTargetRequest) };
14603
14829
  }
@@ -14609,10 +14835,13 @@ async function resolveStatusTargets(options) {
14609
14835
  return { ok: true, value: [target.value] };
14610
14836
  }
14611
14837
  const targets = [];
14838
+ const seenRoots = /* @__PURE__ */ new Set();
14612
14839
  let firstError;
14613
14840
  for (const worktree of requested) {
14614
14841
  const target = await resolveTargetWorktree({ ...options, worktree });
14615
14842
  if (target.ok) {
14843
+ if (seenRoots.has(target.value.worktreeRoot)) continue;
14844
+ seenRoots.add(target.value.worktreeRoot);
14616
14845
  targets.push(target.value);
14617
14846
  } else {
14618
14847
  firstError ??= target.error;
@@ -14630,7 +14859,10 @@ function renderStatus(records, format2, multiTargetRequest) {
14630
14859
  return records.map(renderTextStatus).join("\n");
14631
14860
  }
14632
14861
  function renderTextStatus(record6) {
14633
- return `${record6.worktree} ${record6.status}`;
14862
+ if (record6.status === OCCUPANCY_STATUS.RUNNING) {
14863
+ return `${record6.worktree} ${WORKTREE_STATUS_RENDER.RUNNING_PID_PREFIX}${record6.pid} (${record6.session} @ ${record6.host})`;
14864
+ }
14865
+ return `${record6.worktree} ${WORKTREE_STATUS_RENDER.FREE}`;
14634
14866
  }
14635
14867
 
14636
14868
  // src/lib/worktree-path-info.ts
@@ -14678,7 +14910,7 @@ function registerWorktreeCommands(worktreeCmd) {
14678
14910
  });
14679
14911
  if (!result.ok) handleError3(result.error);
14680
14912
  });
14681
- 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) => {
14913
+ worktreeCmd.command(`${WORKTREE_CLI.STATUS} ${WORKTREE_CLI.WORKTREE_ARGUMENT}`).description("Report a worktree's occupancy (running | free)").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) => {
14682
14914
  const result = await statusCommand2({
14683
14915
  cwd: process.cwd(),
14684
14916
  fs: defaultOccupancyFileSystem,