@biffo/cli 0.308.2 → 0.309.1

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.
@@ -330,6 +330,36 @@
330
330
  "type": "object",
331
331
  "additionalProperties": { "type": "string" },
332
332
  "description": "Python package dependencies (including biffo-plugin-sdk)."
333
+ },
334
+ "config": {
335
+ "type": "array",
336
+ "items": {
337
+ "type": "object",
338
+ "required": ["name", "kind", "description"],
339
+ "properties": {
340
+ "name": {
341
+ "type": "string",
342
+ "pattern": "^[a-z][a-z0-9_]*$",
343
+ "description": "Config key, snake_case. Referenced by other manifest fields (e.g. chat_agents[].required_group) and used as the per-plugin, per-name lookup key the host resolves at runtime."
344
+ },
345
+ "kind": {
346
+ "enum": ["secret", "setting"],
347
+ "description": "'secret' — the instance supplies an SSM parameter PATH, never the credential itself. 'setting' — the instance supplies a literal value (a Cognito group name, a feature flag, a numeric limit). Never a value itself; this manifest declares only the NEED (biffo-template#1517)."
348
+ },
349
+ "required": {
350
+ "type": "boolean",
351
+ "default": true,
352
+ "description": "If true, `biffo plugin install` fails loudly when the instance supplies no value for this name, rather than installing an unconfigured plugin."
353
+ },
354
+ "description": {
355
+ "type": "string",
356
+ "minLength": 1,
357
+ "description": "What this need is for, shown to whoever installs the plugin so they know what to supply."
358
+ }
359
+ },
360
+ "additionalProperties": false
361
+ },
362
+ "description": "Named needs (a credential, a group name, a feature flag) this manifest declares that the INSTANCE must supply at install time — never values, only name/kind/required/description (biffo-template#1517). See ConfigDeclaration in biffo_plugin_sdk.plugin for the full mechanism."
333
363
  }
334
364
  },
335
365
  "additionalProperties": false
@@ -330,6 +330,36 @@
330
330
  "type": "object",
331
331
  "additionalProperties": { "type": "string" },
332
332
  "description": "Python package dependencies (including biffo-plugin-sdk)."
333
+ },
334
+ "config": {
335
+ "type": "array",
336
+ "items": {
337
+ "type": "object",
338
+ "required": ["name", "kind", "description"],
339
+ "properties": {
340
+ "name": {
341
+ "type": "string",
342
+ "pattern": "^[a-z][a-z0-9_]*$",
343
+ "description": "Config key, snake_case. Referenced by other manifest fields (e.g. chat_agents[].required_group) and used as the per-plugin, per-name lookup key the host resolves at runtime."
344
+ },
345
+ "kind": {
346
+ "enum": ["secret", "setting"],
347
+ "description": "'secret' — the instance supplies an SSM parameter PATH, never the credential itself. 'setting' — the instance supplies a literal value (a Cognito group name, a feature flag, a numeric limit). Never a value itself; this manifest declares only the NEED (biffo-template#1517)."
348
+ },
349
+ "required": {
350
+ "type": "boolean",
351
+ "default": true,
352
+ "description": "If true, `biffo plugin install` fails loudly when the instance supplies no value for this name, rather than installing an unconfigured plugin."
353
+ },
354
+ "description": {
355
+ "type": "string",
356
+ "minLength": 1,
357
+ "description": "What this need is for, shown to whoever installs the plugin so they know what to supply."
358
+ }
359
+ },
360
+ "additionalProperties": false
361
+ },
362
+ "description": "Named needs (a credential, a group name, a feature flag) this manifest declares that the INSTANCE must supply at install time — never values, only name/kind/required/description (biffo-template#1517). See ConfigDeclaration in biffo_plugin_sdk.plugin for the full mechanism."
333
363
  }
334
364
  },
335
365
  "additionalProperties": false
package/dist/index.js CHANGED
@@ -630,6 +630,7 @@ import { Command as Command3 } from "commander";
630
630
  // src/adapters/git/index.ts
631
631
  import { randomUUID } from "crypto";
632
632
  import { existsSync as existsSync6, mkdtempSync as mkdtempSync2, rmSync as rmSync2 } from "fs";
633
+ import { readFile as readFile2, rm } from "fs/promises";
633
634
  import { tmpdir as tmpdir2 } from "os";
634
635
  import { join as join6 } from "path";
635
636
 
@@ -1237,6 +1238,51 @@ var GitAdapter = class {
1237
1238
  async hasFleetWorktreeClaim(worktreePath) {
1238
1239
  return existsSync6(join6(worktreePath, ".fleet-worktree-claim"));
1239
1240
  }
1241
+ /**
1242
+ * Milliseconds since `worktreePath`'s `.fleet-worktree-claim` lock was
1243
+ * written (biffo-template#1948), or `null` when there is no lock, or its
1244
+ * `holder` file cannot be read and parsed.
1245
+ *
1246
+ * `bin/fleet.sh worktree-claim` (biffo-fleet) writes the holder token on
1247
+ * line 1 and a UTC ISO-8601 timestamp (`now()`, i.e. `date -u
1248
+ * +%Y-%m-%dT%H:%M:%SZ`) on line 2 — this reads that second line and diffs
1249
+ * it against the current time. `null` is the fail-closed default for
1250
+ * "cannot tell": `doctor-reaper.ts` treats it exactly like a live claim,
1251
+ * never like a stale one, the same posture `hasFleetWorktreeClaim` already
1252
+ * takes and fleet.sh's own comment on an unwritten holder file takes ("An
1253
+ * unwritten holder is still a HELD lock").
1254
+ */
1255
+ async fleetWorktreeClaimAgeMs(worktreePath) {
1256
+ try {
1257
+ const raw = await readFile2(join6(worktreePath, ".fleet-worktree-claim", "holder"), "utf8");
1258
+ const claimedAt = raw.split("\n")[1]?.trim();
1259
+ if (!claimedAt) return null;
1260
+ const claimedMs = Date.parse(claimedAt);
1261
+ return Number.isNaN(claimedMs) ? null : Date.now() - claimedMs;
1262
+ } catch {
1263
+ return null;
1264
+ }
1265
+ }
1266
+ /**
1267
+ * Removes a `.fleet-worktree-claim` lock directory outright (biffo-template#1948).
1268
+ *
1269
+ * Used only once `doctor --fix` has independently judged the lock stale —
1270
+ * see `FLEET_CLAIM_STALE_AFTER_MS` in `lib/doctor-reaper.ts`. Needed because
1271
+ * the lock directory is untracked: leaving it in place after deciding to
1272
+ * disregard it would trip `hasUncommittedChanges`'s `git status
1273
+ * --porcelain` and keep the worktree anyway, under the less specific
1274
+ * `uncommitted-changes` reason, defeating the point of ignoring it.
1275
+ *
1276
+ * Best-effort — swallows any error, so a permissions problem or a
1277
+ * concurrent release falls back to the existing `isDirty` keep path rather
1278
+ * than crashing the whole `--fix` sweep.
1279
+ */
1280
+ async clearStaleFleetWorktreeClaim(worktreePath) {
1281
+ try {
1282
+ await rm(join6(worktreePath, ".fleet-worktree-claim"), { recursive: true, force: true });
1283
+ } catch {
1284
+ }
1285
+ }
1240
1286
  /** Best-effort fetch of the tracking remote, so the ahead/behind check below
1241
1287
  * compares against reality rather than a stale local ref (#394). Never throws:
1242
1288
  * offline or a missing remote must not block an upgrade on its own — the
@@ -8317,6 +8363,12 @@ var ChatAgentDeclarationSchema = z7.object({
8317
8363
  max_output_tokens: z7.number().int().positive().default(1024),
8318
8364
  timeout_seconds: z7.number().positive().default(20)
8319
8365
  }).strict();
8366
+ var ConfigDeclarationSchema = z7.object({
8367
+ name: z7.string().regex(/^[a-z][a-z0-9_]*$/, "must be snake_case, e.g. image_provider_api_key"),
8368
+ kind: z7.enum(["secret", "setting"]),
8369
+ required: z7.boolean().default(true),
8370
+ description: z7.string().min(1, "description is required so an installer knows what this need is for")
8371
+ }).strict();
8320
8372
  var PluginManifestSchema = z7.object({
8321
8373
  name: z7.string().regex(/^[a-z][a-z0-9-]*$/, "must be a lowercase kebab-case slug"),
8322
8374
  version: z7.string().regex(/^\d+\.\d+\.\d+$/, "must be a full semver, e.g. 1.2.3"),
@@ -8350,7 +8402,10 @@ var PluginManifestSchema = z7.object({
8350
8402
  // The plugin's tenant-scoped baseline-row seed (ADR-0005, biffo-template#1554).
8351
8403
  // Optional — a plugin with no baseline data omits this entirely, and
8352
8404
  // `biffo plugin install`/`upgrade` vendor nothing for it.
8353
- seed: SeedDeclarationSchema.optional()
8405
+ seed: SeedDeclarationSchema.optional(),
8406
+ // The needs this plugin declares the instance must supply (biffo-template#1517)
8407
+ // — never values, only name/kind/required/description.
8408
+ config: z7.array(ConfigDeclarationSchema).default([])
8354
8409
  }).superRefine((manifest, ctx) => {
8355
8410
  const tableNames = new Set(manifest.tables.map((t) => t.name));
8356
8411
  for (const route of manifest.api_routes) {
@@ -8371,6 +8426,27 @@ var PluginManifestSchema = z7.object({
8371
8426
  }
8372
8427
  }
8373
8428
  }
8429
+ const configCounts = /* @__PURE__ */ new Map();
8430
+ for (const c of manifest.config) configCounts.set(c.name, (configCounts.get(c.name) ?? 0) + 1);
8431
+ for (const [name, count] of configCounts) {
8432
+ if (count > 1) {
8433
+ ctx.addIssue({
8434
+ code: z7.ZodIssueCode.custom,
8435
+ message: `Duplicate config declaration name '${name}'`
8436
+ });
8437
+ }
8438
+ }
8439
+ const settingNames = new Set(
8440
+ manifest.config.filter((c) => c.kind === "setting").map((c) => c.name)
8441
+ );
8442
+ for (const agent of manifest.chat_agents) {
8443
+ if (!settingNames.has(agent.required_group)) {
8444
+ ctx.addIssue({
8445
+ code: z7.ZodIssueCode.custom,
8446
+ message: `chat_agents[key=${agent.key}].required_group '${agent.required_group}' must name a 'config' declaration with kind: "setting" on this same manifest \u2014 a literal Cognito group name is no longer accepted here (biffo-template#1517). Declared setting config names: ${[...settingNames].sort().join(", ") || "none"}`
8447
+ });
8448
+ }
8449
+ }
8374
8450
  });
8375
8451
  function validateManifest(raw) {
8376
8452
  const result = PluginManifestSchema.safeParse(raw);
@@ -9046,7 +9122,7 @@ function printScopeSeamEntitlement(name) {
9046
9122
  }
9047
9123
 
9048
9124
  // src/commands/plugin-install.ts
9049
- import { cpSync as cpSync5, existsSync as existsSync31, mkdirSync as mkdirSync11, readFileSync as readFileSync23, statSync as statSync7 } from "fs";
9125
+ import { cpSync as cpSync5, existsSync as existsSync31, mkdirSync as mkdirSync11, readFileSync as readFileSync23, statSync as statSync7, writeFileSync as writeFileSync12 } from "fs";
9050
9126
  import { join as join33, relative as relative4, resolve as resolve12 } from "path";
9051
9127
  import chalk15 from "chalk";
9052
9128
  import { Command as Command15 } from "commander";
@@ -9092,6 +9168,58 @@ var PluginMigrationsAdapter = class {
9092
9168
  }
9093
9169
  };
9094
9170
 
9171
+ // src/lib/plugin-config-resolution.ts
9172
+ function pluginConfigEnvNames(pluginName, configName) {
9173
+ const prefix = `BIFFO_PLUGIN_${pluginName.toUpperCase().replace(/-/g, "_")}_${configName.toUpperCase()}`;
9174
+ return { literalEnv: prefix, parameterEnv: `${prefix}_PARAMETER` };
9175
+ }
9176
+ function resolvePluginConfigSupply(pluginName, declarations, supplied) {
9177
+ const resolved = [];
9178
+ const missingRequired = [];
9179
+ for (const decl of declarations) {
9180
+ const raw = supplied[decl.name];
9181
+ const value = raw?.trim();
9182
+ if (!value) {
9183
+ if (decl.required) {
9184
+ missingRequired.push({ name: decl.name, kind: decl.kind, description: decl.description });
9185
+ }
9186
+ continue;
9187
+ }
9188
+ if (decl.kind === "secret" && !value.startsWith("/")) {
9189
+ throw new Error(
9190
+ `--config ${decl.name}=... must be an SSM parameter PATH starting with '/' for a 'secret' declaration (e.g. /myproject/dev/${pluginName}/${decl.name}) \u2014 never the credential itself. Store the credential in SSM first, then pass its parameter name here.`
9191
+ );
9192
+ }
9193
+ const { literalEnv, parameterEnv } = pluginConfigEnvNames(pluginName, decl.name);
9194
+ resolved.push({
9195
+ name: decl.name,
9196
+ kind: decl.kind,
9197
+ description: decl.description,
9198
+ value,
9199
+ envName: decl.kind === "secret" ? parameterEnv : literalEnv
9200
+ });
9201
+ }
9202
+ return { resolved, missingRequired };
9203
+ }
9204
+ function parseConfigOptionValues(entries) {
9205
+ const record = {};
9206
+ for (const entry of entries) {
9207
+ const eq = entry.indexOf("=");
9208
+ if (eq <= 0) {
9209
+ throw new Error(`--config value '${entry}' must be of the form name=value`);
9210
+ }
9211
+ record[entry.slice(0, eq)] = entry.slice(eq + 1);
9212
+ }
9213
+ return record;
9214
+ }
9215
+ function missingRequiredConfigMessage(pluginName, missing) {
9216
+ const lines = missing.map((m) => ` - ${m.name} (${m.kind}): ${m.description}`);
9217
+ return `Plugin '${pluginName}' declares ${missing.length} required config value(s) with no supplied value:
9218
+ ${lines.join("\n")}
9219
+
9220
+ Supply each with --config <name>=<value> (repeatable) and re-run install. For a 'secret', pass the SSM parameter PATH holding the credential (create the parameter first, e.g. \`aws ssm put-parameter --type SecureString ...\`), never the credential itself.`;
9221
+ }
9222
+
9095
9223
  // src/lib/plugin-provenance.ts
9096
9224
  import { existsSync as existsSync28, readFileSync as readFileSync21, writeFileSync as writeFileSync10 } from "fs";
9097
9225
  import { join as join29 } from "path";
@@ -9387,7 +9515,12 @@ var pluginInstallCommand = new Command15("install").description(
9387
9515
  ).argument("[target]", "Plugin name and minor version, e.g. rbac@1.0 (omit when using --local)").option(
9388
9516
  "--local <path>",
9389
9517
  "Install from a local, unpublished plugin directory instead of the registry"
9390
- ).option("--dry-run", "Resolve the plugin and print planned changes without modifying the repo").option("--cwd <path>", "Project root to install into (defaults to the current directory)").action(
9518
+ ).option("--dry-run", "Resolve the plugin and print planned changes without modifying the repo").option("--cwd <path>", "Project root to install into (defaults to the current directory)").option(
9519
+ "--config <name=value>",
9520
+ "Supply an instance value for one manifest `config:` declaration (biffo-template#1517) \u2014 repeatable. For a 'secret' declaration, value must be an SSM parameter PATH, never the credential itself.",
9521
+ (value, previous) => [...previous, value],
9522
+ []
9523
+ ).action(
9391
9524
  async (target, options) => {
9392
9525
  const cwd = options.cwd ? resolve12(options.cwd) : process.cwd();
9393
9526
  try {
@@ -9396,7 +9529,8 @@ var pluginInstallCommand = new Command15("install").description(
9396
9529
  {
9397
9530
  ...options.local ? { local: resolve12(options.local) } : {},
9398
9531
  dryRun: options.dryRun ?? false,
9399
- cwd
9532
+ cwd,
9533
+ config: parseConfigOptionValues(options.config)
9400
9534
  },
9401
9535
  {
9402
9536
  registry: new RegistryAdapter(),
@@ -9501,7 +9635,7 @@ async function runPluginInstall(target, options, deps) {
9501
9635
  );
9502
9636
  }
9503
9637
  if (options.dryRun) {
9504
- printDryRun4(entry, source, relTargetDir, inTreeSource);
9638
+ printDryRun4(entry, source, relTargetDir, inTreeSource, options.config ?? {});
9505
9639
  return;
9506
9640
  }
9507
9641
  const isRepo = await deps.git.isGitRepo(options.cwd);
@@ -9531,6 +9665,14 @@ async function runPluginInstall(target, options, deps) {
9531
9665
  if (retiredShapeReasons.length > 0) {
9532
9666
  throw new Error(retiredFrontendShapeError(pluginName, retiredShapeReasons));
9533
9667
  }
9668
+ const configSupply = resolvePluginConfigSupply(
9669
+ pluginName,
9670
+ manifest.config,
9671
+ options.config ?? {}
9672
+ );
9673
+ if (configSupply.missingRequired.length > 0) {
9674
+ throw new Error(missingRequiredConfigMessage(pluginName, configSupply.missingRequired));
9675
+ }
9534
9676
  if (inTreeSource) {
9535
9677
  log.info(`${relTargetDir}/ is already in this checkout \u2014 installing in place.`);
9536
9678
  } else {
@@ -9589,6 +9731,31 @@ async function runPluginInstall(target, options, deps) {
9589
9731
  if (seedResult.vendored) {
9590
9732
  stagePaths.push(seedResult.stagedPath);
9591
9733
  }
9734
+ if (manifest.config.length > 0) {
9735
+ const configFilePath = join33(targetDir, "biffo.plugin-config.json");
9736
+ writeFileSync12(
9737
+ configFilePath,
9738
+ JSON.stringify(
9739
+ {
9740
+ plugin: pluginName,
9741
+ resolved: configSupply.resolved.map(({ name, kind, envName, value }) => ({
9742
+ name,
9743
+ kind,
9744
+ env: envName,
9745
+ // For a secret this is the SSM parameter PATH, not the credential.
9746
+ value
9747
+ }))
9748
+ },
9749
+ null,
9750
+ 2
9751
+ ) + "\n",
9752
+ "utf8"
9753
+ );
9754
+ stagePaths.push(relative4(options.cwd, configFilePath));
9755
+ log.success(
9756
+ `Recorded ${configSupply.resolved.length}/${manifest.config.length} declared config value(s) at ${relative4(options.cwd, configFilePath)}`
9757
+ );
9758
+ }
9592
9759
  const commitMessage = `feat(plugins): install ${pluginName}@${source.version}`;
9593
9760
  await deps.git.add(options.cwd, stagePaths);
9594
9761
  await deps.git.commit(options.cwd, commitMessage);
@@ -9599,10 +9766,28 @@ async function runPluginInstall(target, options, deps) {
9599
9766
  console.log(chalk15.dim(` git push`));
9600
9767
  console.log(chalk15.dim(` biffo deploy <environment> --app-only
9601
9768
  `));
9769
+ printConfigWiringInstructions(pluginName, configSupply.resolved);
9602
9770
  } finally {
9603
9771
  source.cleanup();
9604
9772
  }
9605
9773
  }
9774
+ function printConfigWiringInstructions(pluginName, resolved) {
9775
+ if (resolved.length === 0) return;
9776
+ console.log(chalk15.bold(` ${pluginName} needs these env vars on the shared plugin host:
9777
+ `));
9778
+ const entries = {};
9779
+ for (const r of resolved) entries[r.envName] = r.value;
9780
+ console.log(chalk15.dim(` ${JSON.stringify(entries, null, 2).split("\n").join("\n ")}`));
9781
+ console.log(
9782
+ `
9783
+ Add them to plugin_host_environment in infra/environments/<env>/plugin_host.auto.tfvars.json.`
9784
+ );
9785
+ if (resolved.some((r) => r.kind === "secret")) {
9786
+ console.log(
9787
+ " At least one is a secret reference \u2014 grant the shared plugin host's execution role ssm:GetParameter (and kms:Decrypt if it's a SecureString on a customer-managed key) scoped to that parameter."
9788
+ );
9789
+ }
9790
+ }
9606
9791
  function parseManifestFile(path) {
9607
9792
  try {
9608
9793
  return JSON.parse(readFileSync23(path, "utf8"));
@@ -9610,7 +9795,7 @@ function parseManifestFile(path) {
9610
9795
  throw new Error(`Could not parse ${path} as JSON: ${err.message}`);
9611
9796
  }
9612
9797
  }
9613
- function printDryRun4(entry, source, relTargetDir, inTreeSource) {
9798
+ function printDryRun4(entry, source, relTargetDir, inTreeSource, suppliedConfig = {}) {
9614
9799
  const name = entry ? entry.name : source.name;
9615
9800
  const version = entry ? entry.version : source.version;
9616
9801
  console.log(chalk15.bold("\n Dry run \u2014 no changes will be made\n"));
@@ -9642,6 +9827,18 @@ function printDryRun4(entry, source, relTargetDir, inTreeSource) {
9642
9827
  ` Would vendor seed DDL into: ${pluginSeedImportDir(name)}/ (baseline_tables: ${source.manifest.seed.baseline_tables.join(", ") || "none declared"})`
9643
9828
  );
9644
9829
  }
9830
+ if (source && source.manifest.config.length > 0) {
9831
+ const { resolved, missingRequired } = resolvePluginConfigSupply(
9832
+ name,
9833
+ source.manifest.config,
9834
+ suppliedConfig
9835
+ );
9836
+ console.log(` Declares ${source.manifest.config.length} config need(s):`);
9837
+ for (const c of source.manifest.config) {
9838
+ const status = missingRequired.some((m) => m.name === c.name) ? "MISSING (would fail install)" : resolved.some((r) => r.name === c.name) ? "supplied" : "not supplied (optional)";
9839
+ console.log(` - ${c.name} (${c.kind}${c.required ? ", required" : ""}): ${status}`);
9840
+ }
9841
+ }
9645
9842
  console.log(` Would commit: feat(plugins): install ${name}@${version}
9646
9843
  `);
9647
9844
  }
@@ -16081,6 +16278,7 @@ function runDoctorChecks(facts) {
16081
16278
  }
16082
16279
 
16083
16280
  // src/lib/doctor-reaper.ts
16281
+ var FLEET_CLAIM_STALE_AFTER_MS = 4 * 60 * 60 * 1e3;
16084
16282
  function classifyReapCandidate(facts) {
16085
16283
  if (facts.hasFleetClaim) return { action: "keep", reason: "fleet-worktree-claimed" };
16086
16284
  if (facts.isDetached) return { action: "keep", reason: "detached-head" };
@@ -16108,12 +16306,23 @@ function findReapCandidates(branches, worktrees) {
16108
16306
  }
16109
16307
  async function reapCandidate(cwd, candidate, deps) {
16110
16308
  const { git, github } = deps;
16111
- const [current, isDirty, hasFleetClaim] = await Promise.all([
16309
+ const [current, claimExists] = await Promise.all([
16112
16310
  git.currentBranch(candidate.worktreePath),
16113
- git.hasUncommittedChanges(candidate.worktreePath),
16114
16311
  git.hasFleetWorktreeClaim(candidate.worktreePath)
16115
16312
  ]);
16116
16313
  const isDetached = current === "HEAD" || current === "";
16314
+ let hasFleetClaim = false;
16315
+ let staleClaimCleared = false;
16316
+ if (claimExists) {
16317
+ const ageMs = await git.fleetWorktreeClaimAgeMs(candidate.worktreePath);
16318
+ if (ageMs === null || ageMs < FLEET_CLAIM_STALE_AFTER_MS) {
16319
+ hasFleetClaim = true;
16320
+ } else {
16321
+ await git.clearStaleFleetWorktreeClaim(candidate.worktreePath);
16322
+ staleClaimCleared = true;
16323
+ }
16324
+ }
16325
+ const isDirty = await git.hasUncommittedChanges(candidate.worktreePath);
16117
16326
  const prVerdict = isDetached || isDirty || hasFleetClaim ? "unknown" : await github.prVerdictForBranch(cwd, candidate.branch);
16118
16327
  let mergeContainsHead = true;
16119
16328
  if (prVerdict === "merged") {
@@ -16131,10 +16340,10 @@ async function reapCandidate(cwd, candidate, deps) {
16131
16340
  mergeContainsHead
16132
16341
  });
16133
16342
  if (verdict.action === "keep") {
16134
- return { candidate, verdict, worktreeRemoved: null };
16343
+ return { candidate, verdict, worktreeRemoved: null, staleClaimCleared };
16135
16344
  }
16136
16345
  const worktreeRemoved = await git.removeWorktree(cwd, candidate.worktreePath);
16137
- return { candidate, verdict, worktreeRemoved };
16346
+ return { candidate, verdict, worktreeRemoved, staleClaimCleared };
16138
16347
  }
16139
16348
  async function reapAll(cwd, branches, worktrees, currentBranch, deps) {
16140
16349
  const candidates = findReapCandidates(branches, worktrees).filter(
@@ -16335,28 +16544,36 @@ function printReapOutcomes(outcomes) {
16335
16544
  const kept = outcomes.filter((o) => o.verdict.action === "keep");
16336
16545
  const removed = attempted.filter((o) => o.worktreeRemoved === true);
16337
16546
  const failed = attempted.filter((o) => o.worktreeRemoved !== true);
16547
+ const staleClaims = outcomes.filter((o) => o.staleClaimCleared);
16338
16548
  console.log("");
16339
16549
  for (const o of attempted) {
16550
+ const staleNote = o.staleClaimCleared ? " (after clearing a stale fleet-worktree-claim lock)" : "";
16340
16551
  if (o.worktreeRemoved === true) {
16341
- console.log(chalk21.green(` removed ${o.candidate.worktreePath} (${o.candidate.branch})`));
16552
+ console.log(
16553
+ chalk21.green(` removed ${o.candidate.worktreePath} (${o.candidate.branch})${staleNote}`)
16554
+ );
16342
16555
  } else {
16343
16556
  console.log(
16344
16557
  chalk21.red(
16345
- ` FAILED ${o.candidate.worktreePath} (${o.candidate.branch}) \u2014 judged safe to remove but 'git worktree remove' did not succeed; left as-is`
16558
+ ` FAILED ${o.candidate.worktreePath} (${o.candidate.branch}) \u2014 judged safe to remove but 'git worktree remove' did not succeed; left as-is${staleNote}`
16346
16559
  )
16347
16560
  );
16348
16561
  }
16349
16562
  }
16350
16563
  for (const o of kept) {
16351
16564
  const reason = o.verdict.reason === void 0 ? "unknown" : KEEP_REASON_TEXT[o.verdict.reason];
16565
+ const staleNote = o.staleClaimCleared ? " (its stale fleet-worktree-claim lock was cleared first)" : "";
16352
16566
  console.log(
16353
- chalk21.dim(` kept ${o.candidate.worktreePath} (${o.candidate.branch}) \u2014 ${reason}`)
16567
+ chalk21.dim(
16568
+ ` kept ${o.candidate.worktreePath} (${o.candidate.branch}) \u2014 ${reason}${staleNote}`
16569
+ )
16354
16570
  );
16355
16571
  }
16572
+ const staleClaimNote = staleClaims.length > 0 ? ` (${String(staleClaims.length)} had a stale fleet-worktree-claim lock cleared)` : "";
16356
16573
  console.log(
16357
16574
  chalk21.dim(
16358
16575
  `
16359
- --fix: ${String(removed.length)} removed, ${String(failed.length)} failed, ${String(kept.length)} kept, of ${String(outcomes.length)} worktree(s) considered.
16576
+ --fix: ${String(removed.length)} removed, ${String(failed.length)} failed, ${String(kept.length)} kept, of ${String(outcomes.length)} worktree(s) considered${staleClaimNote}.
16360
16577
  `
16361
16578
  )
16362
16579
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.308.2",
3
+ "version": "0.309.1",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",