pi-crew 0.9.65 → 0.9.66

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/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  > **Note:** `atomic-write-v2.ts` / `AtomicWriter` mentioned in historical entries below was consolidated into `atomic-write.ts` as of v0.9.42. This changelog is preserved as historical record — the migration was completed (the v2 class was never adopted; v1 won on simplicity + symlink-safety + link+unlink atomicity). See `docs/migration/atomic-write-v2-migration.md` for the decision rationale.
4
4
 
5
+ ## [0.9.66] — real-test findings: cross-project run lookup, output validation, config no-op-write, retry clarity, cron grammar (2026-08-10)
6
+
7
+ Six fixes distilled from the `real-test-pi-crew` 9-tier battery (run 2026-08-10). All verified live end-to-end; `npm run test:critical` 101/101, `npx tsc --noEmit` exit 0, biome lint+format clean.
8
+
9
+ ### Fixes
10
+ - **Cross-project run lookup** (`src/extension/team-tool/explain.ts`, `lifecycle-actions.ts`): `explain` and `worktrees` now resolve runs via `locateRunCwd` (the same cross-project resolver `status`/`cancel`/`inspect` use) instead of raw `ctx.cwd`. A run created with a nested `cwd` override is now reachable from the parent session root (previously "Run not found"). Finding #1.
11
+ - **Output validation accepts markdown** (`src/runtime/output/output-validator.ts`): `ROLE_PATTERN_DEFS` only matched the strict caveman format, so every task tripped `output_validation valid:false`. Each role pattern now also accepts markdown-structured handoffs (`## Handoff`, bullets, bold); structural-preservation checks and empty-output rejection are unchanged. Finding #2.
12
+ - **Config skip-write guard** (`src/config/config.ts`, `types.ts`): `action='config'` with an empty patch went through the write path (`parseConfig({})` yields a full default config → `shouldUpdate=true`) and rewrote `~/.pi/agent/pi-crew.json` on every read. Added a skip-if-unchanged guard (and a `written` flag) so a no-op patch leaves the file untouched. Finding #3.
13
+ - **Retry clarity for completed runs** (`src/extension/team-tool/cancel.ts`): `action='retry'` on a completed run acquired the run lock and surfaced a stale-lock error ("run.lock is locked by another operation"). A pre-lock terminal-status check now short-circuits to "already completed; retry only applies to failed/cancelled runs" before touching the lock. Finding #4.
14
+ - **Cron grammar** (`src/runtime/scheduling/scheduler.ts`): `nextCronDate`'s matcher rejected standard cron step values (`*/30`, `9-17/2`) and named tokens (`MON`, `JAN`), so `action='schedule cron='0 9 * * MON'` errored with "No next cron occurrence found". Rewritten as `cronFieldMatches` handling wildcard, single, range, list, step, and named DOW/month tokens. Cosmetic finding.
15
+ - **"Config unchanged" message** (`src/extension/team-tool-types.ts`, `dispatch/manage.ts`): the config action always said "Updated" even when the skip-write guard left the file untouched; now shows "Config unchanged (no effective changes)." when no write occurred. Cosmetic finding.
16
+
17
+ ### Verified
18
+ - `npm run test:critical`: 101/101 pass (default, `PI_CREW_BROKER=0`, `PI_CREW_BROKER=1`).
19
+ - `npx tsc --noEmit` exit 0; biome lint + format clean.
20
+ - Bundle md5 `a32223b037f35d8605fa3013e1d8a095` (~2857 KB).
21
+ - Full 9-tier `real-test-pi-crew` re-run (2026-08-10): all tiers green, all 6 fixes live-verified. Reports under `docs/real-test/reports/real-test-2026-08-10-full-9-tier*.md`.
22
+
5
23
  ## [0.9.65] — team-tool schema empty-string guard (budgetTotal) + effectiveness empty-result guard + skill drift fix (2026-08-10)
6
24
 
7
25
  ### Fixes
package/dist/index.mjs CHANGED
@@ -10934,11 +10934,15 @@ function updateConfig(patch, options = {}) {
10934
10934
  for (const unset of options.unsetPaths) unsetPath(raw, unset);
10935
10935
  merged = parseConfig(raw);
10936
10936
  }
10937
+ const normalizedCurrent = parseConfig(current);
10938
+ if (JSON.stringify(merged) === JSON.stringify(normalizedCurrent)) {
10939
+ return { path: filePath, config: merged, written: false };
10940
+ }
10937
10941
  fs5.mkdirSync(path5.dirname(filePath), { recursive: true });
10938
10942
  atomicWriteFile(filePath, `${JSON.stringify(merged, null, 2)}
10939
10943
  `);
10940
10944
  invalidateConfigCache();
10941
- return { path: filePath, config: merged };
10945
+ return { path: filePath, config: merged, written: true };
10942
10946
  });
10943
10947
  }
10944
10948
  function updateAutonomousConfig(patch) {
@@ -10953,11 +10957,15 @@ function updateAutonomousConfig(patch) {
10953
10957
  throw new Error(`Could not update pi-crew config: ${message}`);
10954
10958
  }
10955
10959
  const currentAutonomous = current.autonomous && typeof current.autonomous === "object" && !Array.isArray(current.autonomous) ? current.autonomous : {};
10956
- current.autonomous = { ...currentAutonomous, ...patch };
10960
+ const next = { ...current, autonomous: { ...currentAutonomous, ...patch } };
10961
+ if (JSON.stringify(next) === JSON.stringify(current)) {
10962
+ return { path: filePath, config: parseConfig(current), written: false };
10963
+ }
10964
+ current.autonomous = next.autonomous;
10957
10965
  atomicWriteFile(filePath, `${JSON.stringify(current, null, 2)}
10958
10966
  `);
10959
10967
  invalidateConfigCache();
10960
- return { path: filePath, config: parseConfig(current) };
10968
+ return { path: filePath, config: parseConfig(current), written: true };
10961
10969
  });
10962
10970
  }
10963
10971
  var CONFIG_CACHE_TTL_MS, configCache, configCacheTtlMsOverride, KNOWN_TOP_LEVEL_KEYS, LIMIT_CEILINGS, DANGEROUS_OBJECT_KEYS;
@@ -35647,23 +35655,46 @@ function parseIntervalMs(s) {
35647
35655
  }
35648
35656
  return ms;
35649
35657
  }
35658
+ function cronFieldMatches(value, field, min, max, names) {
35659
+ let normalized = field.trim().toUpperCase();
35660
+ if (names) {
35661
+ for (const name of Object.keys(names).sort((a, b) => b.length - a.length)) {
35662
+ normalized = normalized.split(name).join(String(names[name]));
35663
+ }
35664
+ }
35665
+ if (min === 0 && max === 6) normalized = normalized.replace(/\b7\b/g, "0");
35666
+ const matched = /* @__PURE__ */ new Set();
35667
+ for (const rawPart of normalized.split(",")) {
35668
+ const part = rawPart.trim();
35669
+ if (part === "") return false;
35670
+ const stepMatch = part.match(/^(.*)\/(\d+)$/);
35671
+ const step = stepMatch ? Number.parseInt(stepMatch[2], 10) : 1;
35672
+ if (!Number.isFinite(step) || step < 1) return false;
35673
+ const rangeStr = stepMatch ? stepMatch[1] : part;
35674
+ let lo;
35675
+ let hi;
35676
+ if (rangeStr === "*") {
35677
+ lo = min;
35678
+ hi = max;
35679
+ } else if (/^\d+$/.test(rangeStr)) {
35680
+ lo = Number.parseInt(rangeStr, 10);
35681
+ hi = stepMatch ? max : lo;
35682
+ } else {
35683
+ const rm = rangeStr.match(/^(\d+)-(\d+)$/);
35684
+ if (!rm) return false;
35685
+ lo = Number.parseInt(rm[1], 10);
35686
+ hi = Number.parseInt(rm[2], 10);
35687
+ }
35688
+ for (let v = lo; v <= hi; v += step) {
35689
+ if (v >= min && v <= max) matched.add(v);
35690
+ }
35691
+ }
35692
+ return matched.has(value);
35693
+ }
35650
35694
  function nextCronDate(spec, from) {
35651
35695
  const parts = spec.split(/\s+/);
35652
35696
  if (parts.length < 5) return { error: "Invalid cron expression" };
35653
35697
  const [minStr, hourStr, domStr, monthStr, dowStr] = parts;
35654
- function matchField(value, str, min, max) {
35655
- if (str === "*") return true;
35656
- const n = parseInt(str, 10);
35657
- if (!Number.isNaN(n) && n >= min && n <= max && n === value) return true;
35658
- if (/^\d+-\d+$/.test(str)) {
35659
- const [a, b] = str.split("-").map(Number);
35660
- return value >= a && value <= b;
35661
- }
35662
- if (str.includes(",")) {
35663
- return str.split(",").some((part) => matchField(value, part.trim(), min, max));
35664
- }
35665
- return false;
35666
- }
35667
35698
  let cursor = new Date(from.getTime());
35668
35699
  cursor.setSeconds(0, 0);
35669
35700
  cursor = new Date(cursor.getTime() + 6e4);
@@ -35674,7 +35705,7 @@ function nextCronDate(spec, from) {
35674
35705
  const dom = cursor.getUTCDate();
35675
35706
  const month = cursor.getUTCMonth() + 1;
35676
35707
  const dow = cursor.getUTCDay();
35677
- if (matchField(min, minStr, 0, 59) && matchField(hour, hourStr, 0, 23) && matchField(dom, domStr, 1, 31) && matchField(month, monthStr, 1, 12) && matchField(dow, dowStr, 0, 6)) {
35708
+ if (cronFieldMatches(min, minStr, 0, 59) && cronFieldMatches(hour, hourStr, 0, 23) && cronFieldMatches(dom, domStr, 1, 31) && cronFieldMatches(month, monthStr, 1, 12, CRON_MONTH_NAMES) && cronFieldMatches(dow, dowStr, 0, 6, CRON_DOW_NAMES)) {
35678
35709
  return cursor;
35679
35710
  }
35680
35711
  cursor = new Date(cursor.getTime() + 6e4);
@@ -35752,7 +35783,7 @@ function humanizeSchedule(spec) {
35752
35783
  }
35753
35784
  return "unknown schedule";
35754
35785
  }
35755
- var CrewScheduler;
35786
+ var CrewScheduler, CRON_DOW_NAMES, CRON_MONTH_NAMES;
35756
35787
  var init_scheduler = __esm({
35757
35788
  "src/runtime/scheduling/scheduler.ts"() {
35758
35789
  "use strict";
@@ -35907,6 +35938,21 @@ var init_scheduler = __esm({
35907
35938
  throw new Error(`Invalid schedule "${s}". Use "5m", "+10m", ISO timestamp, or cron expression.`);
35908
35939
  }
35909
35940
  };
35941
+ CRON_DOW_NAMES = { SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6 };
35942
+ CRON_MONTH_NAMES = {
35943
+ JAN: 1,
35944
+ FEB: 2,
35945
+ MAR: 3,
35946
+ APR: 4,
35947
+ MAY: 5,
35948
+ JUN: 6,
35949
+ JUL: 7,
35950
+ AUG: 8,
35951
+ SEP: 9,
35952
+ OCT: 10,
35953
+ NOV: 11,
35954
+ DEC: 12
35955
+ };
35910
35956
  }
35911
35957
  });
35912
35958
 
@@ -36532,8 +36578,13 @@ var cancel_exports = {};
36532
36578
  __export(cancel_exports, {
36533
36579
  abortOwned: () => abortOwned,
36534
36580
  handleCancel: () => handleCancel,
36535
- handleRetry: () => handleRetry
36581
+ handleRetry: () => handleRetry,
36582
+ retryShortCircuitsCompleted: () => retryShortCircuitsCompleted
36536
36583
  });
36584
+ function retryShortCircuitsCompleted(runStatus, tasks, targetTaskId) {
36585
+ if (runStatus !== "completed") return false;
36586
+ return !tasks.some((task) => (targetTaskId ? task.id === targetTaskId : true) && RETRYABLE_STATUSES.has(task.status));
36587
+ }
36537
36588
  function abortOwned(runId, taskIds, ctx, force) {
36538
36589
  const runCwd = locateRunCwd(runId, ctx.cwd);
36539
36590
  if (!runCwd) return { abortedIds: [], missingIds: taskIds ?? [], foreignIds: [] };
@@ -36606,6 +36657,13 @@ async function handleRetry(params, ctx, deps) {
36606
36657
  );
36607
36658
  }
36608
36659
  const targetTaskId = typeof params.taskId === "string" ? params.taskId : void 0;
36660
+ if (retryShortCircuitsCompleted(loaded.manifest.status, loaded.tasks, targetTaskId)) {
36661
+ return result(
36662
+ `Run ${loaded.manifest.runId} is already completed; retry only applies to failed/cancelled runs.`,
36663
+ { action: "retry", status: "error", runId: loaded.manifest.runId },
36664
+ true
36665
+ );
36666
+ }
36609
36667
  return withRunLockSync(loaded.manifest, () => {
36610
36668
  const retryableStatuses = /* @__PURE__ */ new Set(["failed", "cancelled"]);
36611
36669
  const matchingTasks = loaded.tasks.filter((task) => {
@@ -36810,6 +36868,7 @@ async function handleCancel(params, ctx, deps) {
36810
36868
  });
36811
36869
  });
36812
36870
  }
36871
+ var RETRYABLE_STATUSES;
36813
36872
  var init_cancel = __esm({
36814
36873
  "src/extension/team-tool/cancel.ts"() {
36815
36874
  "use strict";
@@ -36829,6 +36888,7 @@ var init_cancel = __esm({
36829
36888
  init_intent_policy();
36830
36889
  init_param_error();
36831
36890
  init_run_not_found();
36891
+ RETRYABLE_STATUSES = /* @__PURE__ */ new Set(["failed", "cancelled"]);
36832
36892
  }
36833
36893
  });
36834
36894
 
@@ -38806,7 +38866,9 @@ function handleWorktrees(params, ctx) {
38806
38866
  { action: "worktrees", status: "error" },
38807
38867
  true
38808
38868
  );
38809
- const loaded = loadRunManifestById(ctx.cwd, params.runId);
38869
+ const runCwd = locateRunCwd(params.runId, ctx.cwd);
38870
+ if (!runCwd) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "worktrees", status: "error" }, true);
38871
+ const loaded = loadRunManifestById(runCwd, params.runId);
38810
38872
  if (!loaded) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "worktrees", status: "error" }, true);
38811
38873
  const withWorktrees = loaded.tasks.filter((task) => task.worktree);
38812
38874
  const lines = [
@@ -39331,6 +39393,7 @@ var init_lifecycle_actions = __esm({
39331
39393
  init_run_export();
39332
39394
  init_run_import();
39333
39395
  init_run_maintenance();
39396
+ init_team_tool2();
39334
39397
  init_context();
39335
39398
  init_intent_policy();
39336
39399
  init_param_error();
@@ -41167,10 +41230,13 @@ async function handleManageDomain(params, ctx) {
41167
41230
  unsetPaths
41168
41231
  });
41169
41232
  return result(
41170
- ["Updated pi-crew config.", `Path: ${saved.path}`, "Effective config:", JSON.stringify(saved.config, null, 2)].join(
41171
- "\n"
41172
- ),
41173
- { action: "config", status: "ok" }
41233
+ [
41234
+ saved.written ? "Updated pi-crew config." : "Config unchanged (no effective changes).",
41235
+ `Path: ${saved.path}`,
41236
+ "Effective config:",
41237
+ JSON.stringify(saved.config, null, 2)
41238
+ ].join("\n"),
41239
+ { action: "config", status: "ok", written: saved.written }
41174
41240
  );
41175
41241
  } catch (error) {
41176
41242
  const message = error instanceof Error ? error.message : String(error);
@@ -47796,7 +47862,8 @@ function handleExplain(params, cwd) {
47796
47862
  if (!params.runId) {
47797
47863
  return result3("explain requires runId", { action: "explain", status: "error" }, true);
47798
47864
  }
47799
- const loaded = loadRunManifestById(cwd, params.runId);
47865
+ const runCwd = locateRunCwd(params.runId, cwd);
47866
+ const loaded = runCwd ? loadRunManifestById(runCwd, params.runId) : void 0;
47800
47867
  if (!loaded) {
47801
47868
  return result3(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "explain", status: "error" }, true);
47802
47869
  }
@@ -47855,6 +47922,7 @@ var init_explain = __esm({
47855
47922
  "src/extension/team-tool/explain.ts"() {
47856
47923
  "use strict";
47857
47924
  init_state_store();
47925
+ init_team_tool2();
47858
47926
  init_run_not_found();
47859
47927
  }
47860
47928
  });
@@ -54561,16 +54629,24 @@ function validateWorkerOutput(role, output) {
54561
54629
  issues
54562
54630
  };
54563
54631
  }
54564
- var ROLE_PATTERN_DEFS, makeUrlRe;
54632
+ var MARKDOWN_STRUCTURED, STRICT_ROLE_PATTERNS, ROLE_PATTERN_DEFS, makeUrlRe;
54565
54633
  var init_output_validator = __esm({
54566
54634
  "src/runtime/output/output-validator.ts"() {
54567
54635
  "use strict";
54636
+ MARKDOWN_STRUCTURED = /^(?:#{1,6}\s|\*\*|[-*]\s|\d+\.\s)/m;
54637
+ STRICT_ROLE_PATTERNS = {
54638
+ explorer: /^(\S+:\d+|Defs:|Refs:|Callers:|Tests:|Sites:|No match\.|totals:)/m,
54639
+ executor: /^(\S+:\d+(-\d+)? — .{1,80}\.|verified:|too-big\.|needs-confirm\.|ambiguous\.|regressed\.)/m,
54640
+ reviewer: new RegExp("^([^:\\s]+:\\d+:\\s+\\p{Emoji_Presentation}|No issues\\.|totals:)", "mu"),
54641
+ "security-reviewer": new RegExp("^([^:\\s]+:\\d+:\\s+\\p{Emoji_Presentation}|No issues\\.|totals:)", "mu"),
54642
+ verifier: /^(PASS:|FAIL:)/m
54643
+ };
54568
54644
  ROLE_PATTERN_DEFS = {
54569
- explorer: () => /^(\S+:\d+|Defs:|Refs:|Callers:|Tests:|Sites:|No match\.|totals:)/m,
54570
- executor: () => /^(\S+:\d+(-\d+)? — .{1,80}\.|verified:|too-big\.|needs-confirm\.|ambiguous\.|regressed\.)/m,
54571
- reviewer: () => new RegExp("^([^:\\s]+:\\d+:\\s+\\p{Emoji_Presentation}|No issues\\.|totals:)", "mu"),
54572
- "security-reviewer": () => new RegExp("^([^:\\s]+:\\d+:\\s+\\p{Emoji_Presentation}|No issues\\.|totals:)", "mu"),
54573
- verifier: () => /^(PASS:|FAIL:)/m
54645
+ explorer: () => new RegExp(`(?:${STRICT_ROLE_PATTERNS.explorer.source})|(?:${MARKDOWN_STRUCTURED.source})`, "m"),
54646
+ executor: () => new RegExp(`(?:${STRICT_ROLE_PATTERNS.executor.source})|(?:${MARKDOWN_STRUCTURED.source})`, "m"),
54647
+ reviewer: () => new RegExp(`(?:${STRICT_ROLE_PATTERNS.reviewer.source})|(?:${MARKDOWN_STRUCTURED.source})`, "mu"),
54648
+ "security-reviewer": () => new RegExp(`(?:${STRICT_ROLE_PATTERNS["security-reviewer"].source})|(?:${MARKDOWN_STRUCTURED.source})`, "mu"),
54649
+ verifier: () => new RegExp(`(?:${STRICT_ROLE_PATTERNS.verifier.source})|(?:${MARKDOWN_STRUCTURED.source})`, "m")
54574
54650
  };
54575
54651
  makeUrlRe = () => /\bhttps?:\/\/[^\s<>)\]"',;]+/gi;
54576
54652
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.9.65",
3
+ "version": "0.9.66",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -1249,12 +1249,20 @@ export function updateConfig(patch: PiTeamsConfig, options: UpdateConfigOptions
1249
1249
  for (const unset of options.unsetPaths) unsetPath(raw, unset);
1250
1250
  merged = parseConfig(raw);
1251
1251
  }
1252
+ // Skip-if-unchanged: an empty/identical patch must not rewrite the file
1253
+ // (e.g. `team action='config'` with an empty patch — read-only path).
1254
+ // Both sides are parseConfig-normalized, so JSON.stringify key order is
1255
+ // deterministic (same construction path); no key sorting needed.
1256
+ const normalizedCurrent = parseConfig(current);
1257
+ if (JSON.stringify(merged) === JSON.stringify(normalizedCurrent)) {
1258
+ return { path: filePath, config: merged, written: false }; // unchanged — skip write
1259
+ }
1252
1260
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
1253
1261
  atomicWriteFile(filePath, `${JSON.stringify(merged, null, 2)}\n`);
1254
1262
  // (F16) Invalidate the loadConfig cache after a write — the next
1255
1263
  // caller must see the new value, not a 0-2s stale snapshot.
1256
1264
  invalidateConfigCache();
1257
- return { path: filePath, config: merged };
1265
+ return { path: filePath, config: merged, written: true };
1258
1266
  });
1259
1267
  }
1260
1268
 
@@ -1273,10 +1281,18 @@ export function updateAutonomousConfig(patch: PiTeamsAutonomousConfig): SavedPiT
1273
1281
  current.autonomous && typeof current.autonomous === "object" && !Array.isArray(current.autonomous)
1274
1282
  ? (current.autonomous as Record<string, unknown>)
1275
1283
  : {};
1276
- current.autonomous = { ...currentAutonomous, ...patch };
1284
+ // Skip-if-unchanged (raw shape): a no-op autonomous patch must not
1285
+ // rewrite the file. NOTE: compare the RAW on-disk record, NOT the
1286
+ // parseConfig-normalized shape — normalizing would add default keys and
1287
+ // false-positive the equality check.
1288
+ const next = { ...current, autonomous: { ...currentAutonomous, ...patch } };
1289
+ if (JSON.stringify(next) === JSON.stringify(current)) {
1290
+ return { path: filePath, config: parseConfig(current), written: false }; // unchanged — skip write
1291
+ }
1292
+ current.autonomous = next.autonomous;
1277
1293
  atomicWriteFile(filePath, `${JSON.stringify(current, null, 2)}\n`);
1278
1294
  // (F16) Invalidate the loadConfig cache after a write — see updateConfig.
1279
1295
  invalidateConfigCache();
1280
- return { path: filePath, config: parseConfig(current) };
1296
+ return { path: filePath, config: parseConfig(current), written: true };
1281
1297
  });
1282
1298
  }
@@ -312,6 +312,8 @@ export interface ConfigValidationResult {
312
312
  export interface SavedPiTeamsConfig {
313
313
  config: PiTeamsConfig;
314
314
  path: string;
315
+ /** Whether the file was actually rewritten. `false` when a no-op patch hit the skip-write guard. */
316
+ written: boolean;
315
317
  }
316
318
 
317
319
  export interface UpdateConfigOptions {
@@ -21,6 +21,28 @@ import { enforceDestructiveIntent, intentFromConfig } from "./intent-policy.ts";
21
21
  import { paramRequired } from "./param-error.ts";
22
22
  import { RUN_NOT_FOUND_HINT } from "./run-not-found.ts";
23
23
 
24
+ /** Retryable terminal statuses (a task in one of these can be re-queued). */
25
+ const RETRYABLE_STATUSES: ReadonlySet<string> = new Set(["failed", "cancelled"]);
26
+
27
+ /**
28
+ * Pure pre-lock decision for `action='retry'`: a run whose manifest status is
29
+ * "completed" (terminal success) and has no retryable tasks has nothing to
30
+ * retry. Returns true so the caller short-circuits with a clear message
31
+ * BEFORE acquiring the run lock — avoiding a misleading
32
+ * "run.lock is locked by another operation" error from a stale lock file left
33
+ * behind by a completed async run (finding #4, real-test-2026-08-10-full-9-tier).
34
+ *
35
+ * Exported for unit testing (handleRetry itself needs filesystem state).
36
+ */
37
+ export function retryShortCircuitsCompleted(
38
+ runStatus: string,
39
+ tasks: ReadonlyArray<{ id: string; status: string }>,
40
+ targetTaskId?: string,
41
+ ): boolean {
42
+ if (runStatus !== "completed") return false;
43
+ return !tasks.some((task) => (targetTaskId ? task.id === targetTaskId : true) && RETRYABLE_STATUSES.has(task.status));
44
+ }
45
+
24
46
  export interface AbortOwnedResult {
25
47
  abortedIds: string[];
26
48
  missingIds: string[];
@@ -126,6 +148,18 @@ export async function handleRetry(params: TeamToolParamsValue, ctx: TeamContext,
126
148
 
127
149
  const targetTaskId = typeof params.taskId === "string" ? params.taskId : undefined;
128
150
 
151
+ // Pre-lock terminal-status check: a completed run has nothing to retry.
152
+ // Short-circuit BEFORE acquiring the run lock so a stale lock file left by a
153
+ // completed async run does not surface a misleading "run.lock is locked by
154
+ // another operation" error (finding #4 in real-test-2026-08-10-full-9-tier).
155
+ if (retryShortCircuitsCompleted(loaded.manifest.status, loaded.tasks, targetTaskId)) {
156
+ return result(
157
+ `Run ${loaded.manifest.runId} is already completed; retry only applies to failed/cancelled runs.`,
158
+ { action: "retry", status: "error", runId: loaded.manifest.runId },
159
+ true,
160
+ );
161
+ }
162
+
129
163
  return withRunLockSync(loaded.manifest, () => {
130
164
  const retryableStatuses: ReadonlySet<string> = new Set(["failed", "cancelled"]);
131
165
 
@@ -130,10 +130,13 @@ export async function handleManageDomain(params: TeamToolParamsValue, ctx: TeamC
130
130
  unsetPaths,
131
131
  });
132
132
  return result(
133
- ["Updated pi-crew config.", `Path: ${saved.path}`, "Effective config:", JSON.stringify(saved.config, null, 2)].join(
134
- "\n",
135
- ),
136
- { action: "config", status: "ok" },
133
+ [
134
+ saved.written ? "Updated pi-crew config." : "Config unchanged (no effective changes).",
135
+ `Path: ${saved.path}`,
136
+ "Effective config:",
137
+ JSON.stringify(saved.config, null, 2),
138
+ ].join("\n"),
139
+ { action: "config", status: "ok", written: saved.written },
137
140
  );
138
141
  } catch (error) {
139
142
  const message = error instanceof Error ? error.message : String(error);
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { loadRunManifestById } from "../../state/stores/state-store.ts";
4
4
  import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
5
+ import { locateRunCwd } from "../team-tool.ts";
5
6
  import { RUN_NOT_FOUND_HINT } from "./run-not-found.ts";
6
7
 
7
8
  /**
@@ -215,7 +216,8 @@ export function handleExplain(
215
216
  return result("explain requires runId", { action: "explain", status: "error" }, true);
216
217
  }
217
218
 
218
- const loaded = loadRunManifestById(cwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
219
+ const runCwd = locateRunCwd(params.runId, cwd);
220
+ const loaded = runCwd ? loadRunManifestById(runCwd, params.runId) : undefined; // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
219
221
  if (!loaded) {
220
222
  return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "explain", status: "error" }, true);
221
223
  }
@@ -16,6 +16,7 @@ import { listImportedRuns } from "../import-index.ts";
16
16
  import { exportRunBundle } from "../run-export.ts";
17
17
  import { importRunBundle } from "../run-import.ts";
18
18
  import { pruneFinishedRuns } from "../run-maintenance.ts";
19
+ import { locateRunCwd } from "../team-tool.ts";
19
20
  import type { PiTeamsToolResult } from "../tool-result.ts";
20
21
  import { configRecord, result, type TeamContext } from "./context.ts";
21
22
  import { enforceDestructiveIntent, intentFromConfig } from "./intent-policy.ts";
@@ -29,7 +30,9 @@ export function handleWorktrees(params: TeamToolParamsValue, ctx: TeamContext):
29
30
  { action: "worktrees", status: "error" },
30
31
  true,
31
32
  );
32
- const loaded = loadRunManifestById(ctx.cwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
33
+ const runCwd = locateRunCwd(params.runId, ctx.cwd);
34
+ if (!runCwd) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "worktrees", status: "error" }, true);
35
+ const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
33
36
  if (!loaded) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "worktrees", status: "error" }, true);
34
37
  const withWorktrees = loaded.tasks.filter((task) => task.worktree);
35
38
  const lines = [
@@ -10,6 +10,8 @@ export interface TeamToolDetails {
10
10
  resumedIds?: string[];
11
11
  retriedTaskIds?: string[];
12
12
  mailboxIds?: string[];
13
+ /** Whether a config write actually persisted (false on no-op/skip-write). */
14
+ written?: boolean;
13
15
  /** Resource scope affected by the action (e.g. cleanup: "project"). */
14
16
  scope?: string;
15
17
  /** Run metrics for compact display in TUI tool result rendering. */
@@ -9,13 +9,41 @@
9
9
  * (headings, code blocks, URLs) after compression.
10
10
  */
11
11
 
12
- /** Role-specific output format patterns — constructed fresh per call to avoid /g lastIndex leak */
12
+ /**
13
+ * Why relax: real worker LLMs emit markdown handoffs (`## Handoff`,
14
+ * `### Summary`, `## Follow-ups`, `- bullet`, `**bold**`) instead of the
15
+ * caveman formats (`file:line — text`, `PASS:`/`FAIL:`, emoji findings)
16
+ * these patterns originally required. Each role pattern below ORs its
17
+ * strict contract with a markdown-structured alternation so structured
18
+ * handoffs validate while empty/garbage output still fails.
19
+ *
20
+ * What changed: added the MARKDOWN_STRUCTURED alternation to every role.
21
+ * What is preserved: the strict patterns still match verbatim, and the
22
+ * structural-preservation checks (code blocks, URLs, headings) in
23
+ * validateWorkerOutput are UNCHANGED.
24
+ */
25
+
26
+ /** Accepts atx headings (`## X`), bold (`**x**`), bullets (`- x` / `* x`), and numbered lists (`1. x`) */
27
+ const MARKDOWN_STRUCTURED = /^(?:#{1,6}\s|\*\*|[-*]\s|\d+\.\s)/m;
28
+
29
+ /** Strict per-role contract patterns (kept verbatim; `.source` is embedded in the alternations below) */
30
+ const STRICT_ROLE_PATTERNS: Record<string, RegExp> = {
31
+ explorer: /^(\S+:\d+|Defs:|Refs:|Callers:|Tests:|Sites:|No match\.|totals:)/m,
32
+ executor: /^(\S+:\d+(-\d+)? — .{1,80}\.|verified:|too-big\.|needs-confirm\.|ambiguous\.|regressed\.)/m,
33
+ reviewer: /^([^:\s]+:\d+:\s+\p{Emoji_Presentation}|No issues\.|totals:)/mu,
34
+ "security-reviewer": /^([^:\s]+:\d+:\s+\p{Emoji_Presentation}|No issues\.|totals:)/mu,
35
+ verifier: /^(PASS:|FAIL:)/m,
36
+ };
37
+
38
+ /** Role-specific output format patterns — constructed fresh per call to avoid /g lastIndex leak.
39
+ * Each factory: strict contract OR markdown-structured alternation. */
13
40
  const ROLE_PATTERN_DEFS: Record<string, () => RegExp> = {
14
- explorer: () => /^(\S+:\d+|Defs:|Refs:|Callers:|Tests:|Sites:|No match\.|totals:)/m,
15
- executor: () => /^(\S+:\d+(-\d+)? — .{1,80}\.|verified:|too-big\.|needs-confirm\.|ambiguous\.|regressed\.)/m,
16
- reviewer: () => /^([^:\s]+:\d+:\s+\p{Emoji_Presentation}|No issues\.|totals:)/mu,
17
- "security-reviewer": () => /^([^:\s]+:\d+:\s+\p{Emoji_Presentation}|No issues\.|totals:)/mu,
18
- verifier: () => /^(PASS:|FAIL:)/m,
41
+ explorer: () => new RegExp(`(?:${STRICT_ROLE_PATTERNS.explorer.source})|(?:${MARKDOWN_STRUCTURED.source})`, "m"),
42
+ executor: () => new RegExp(`(?:${STRICT_ROLE_PATTERNS.executor.source})|(?:${MARKDOWN_STRUCTURED.source})`, "m"),
43
+ reviewer: () => new RegExp(`(?:${STRICT_ROLE_PATTERNS.reviewer.source})|(?:${MARKDOWN_STRUCTURED.source})`, "mu"),
44
+ "security-reviewer": () =>
45
+ new RegExp(`(?:${STRICT_ROLE_PATTERNS["security-reviewer"].source})|(?:${MARKDOWN_STRUCTURED.source})`, "mu"),
46
+ verifier: () => new RegExp(`(?:${STRICT_ROLE_PATTERNS.verifier.source})|(?:${MARKDOWN_STRUCTURED.source})`, "m"),
19
47
  };
20
48
 
21
49
  /** Fresh RegExp factories for structural preservation checks (avoids /g lastIndex leak) */
@@ -229,24 +229,72 @@ function parseIntervalMs(s: string): number | undefined {
229
229
  return ms;
230
230
  }
231
231
 
232
- function nextCronDate(spec: string, from: Date): Date | { error: string } | null {
233
- const parts = spec.split(/\s+/);
234
- if (parts.length < 5) return { error: "Invalid cron expression" };
235
- const [minStr, hourStr, domStr, monthStr, dowStr] = parts;
232
+ /** Named-token maps for cron DOW (SUN=0..SAT=6) and month (JAN=1..DEC=12). */
233
+ const CRON_DOW_NAMES: Record<string, number> = { SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6 };
234
+ const CRON_MONTH_NAMES: Record<string, number> = {
235
+ JAN: 1,
236
+ FEB: 2,
237
+ MAR: 3,
238
+ APR: 4,
239
+ MAY: 5,
240
+ JUN: 6,
241
+ JUL: 7,
242
+ AUG: 8,
243
+ SEP: 9,
244
+ OCT: 10,
245
+ NOV: 11,
246
+ DEC: 12,
247
+ };
236
248
 
237
- function matchField(value: number, str: string, min: number, max: number): boolean {
238
- if (str === "*") return true;
239
- const n = parseInt(str, 10);
240
- if (!Number.isNaN(n) && n >= min && n <= max && n === value) return true;
241
- if (/^\d+-\d+$/.test(str)) {
242
- const [a, b] = str.split("-").map(Number);
243
- return value >= a && value <= b;
249
+ /**
250
+ * Match a single cron field value against a cron field expression.
251
+ * Supports the standard cron grammar: wildcard, single N, range a-b, list a,b,c,
252
+ * step syntax (wildcard-step, range-step, from-step), and named tokens
253
+ * (MON, JAN) when a `names` map is passed. Fixes the prior matcher that
254
+ * rejected step values and named DOW (parseInt returned NaN).
255
+ */
256
+ function cronFieldMatches(value: number, field: string, min: number, max: number, names?: Record<string, number>): boolean {
257
+ let normalized = field.trim().toUpperCase();
258
+ if (names) {
259
+ for (const name of Object.keys(names).sort((a, b) => b.length - a.length)) {
260
+ normalized = normalized.split(name).join(String(names[name]));
261
+ }
262
+ }
263
+ // Cron permits 7 for Sunday in the DOW field — normalize to 0.
264
+ if (min === 0 && max === 6) normalized = normalized.replace(/\b7\b/g, "0");
265
+ const matched = new Set<number>();
266
+ for (const rawPart of normalized.split(",")) {
267
+ const part = rawPart.trim();
268
+ if (part === "") return false;
269
+ const stepMatch = part.match(/^(.*)\/(\d+)$/);
270
+ const step = stepMatch ? Number.parseInt(stepMatch[2], 10) : 1;
271
+ if (!Number.isFinite(step) || step < 1) return false;
272
+ const rangeStr = stepMatch ? stepMatch[1] : part;
273
+ let lo: number;
274
+ let hi: number;
275
+ if (rangeStr === "*") {
276
+ lo = min;
277
+ hi = max;
278
+ } else if (/^\d+$/.test(rangeStr)) {
279
+ lo = Number.parseInt(rangeStr, 10);
280
+ hi = stepMatch ? max : lo; // bare `N` = single value; `N/S` = N..max step S
281
+ } else {
282
+ const rm = rangeStr.match(/^(\d+)-(\d+)$/);
283
+ if (!rm) return false;
284
+ lo = Number.parseInt(rm[1], 10);
285
+ hi = Number.parseInt(rm[2], 10);
244
286
  }
245
- if (str.includes(",")) {
246
- return str.split(",").some((part) => matchField(value, part.trim(), min, max));
287
+ for (let v = lo; v <= hi; v += step) {
288
+ if (v >= min && v <= max) matched.add(v);
247
289
  }
248
- return false;
249
290
  }
291
+ return matched.has(value);
292
+ }
293
+
294
+ function nextCronDate(spec: string, from: Date): Date | { error: string } | null {
295
+ const parts = spec.split(/\s+/);
296
+ if (parts.length < 5) return { error: "Invalid cron expression" };
297
+ const [minStr, hourStr, domStr, monthStr, dowStr] = parts;
250
298
 
251
299
  let cursor = new Date(from.getTime());
252
300
  cursor.setSeconds(0, 0);
@@ -261,11 +309,11 @@ function nextCronDate(spec: string, from: Date): Date | { error: string } | null
261
309
  const dow = cursor.getUTCDay();
262
310
 
263
311
  if (
264
- matchField(min, minStr, 0, 59) &&
265
- matchField(hour, hourStr, 0, 23) &&
266
- matchField(dom, domStr, 1, 31) &&
267
- matchField(month, monthStr, 1, 12) &&
268
- matchField(dow, dowStr, 0, 6)
312
+ cronFieldMatches(min, minStr, 0, 59) &&
313
+ cronFieldMatches(hour, hourStr, 0, 23) &&
314
+ cronFieldMatches(dom, domStr, 1, 31) &&
315
+ cronFieldMatches(month, monthStr, 1, 12, CRON_MONTH_NAMES) &&
316
+ cronFieldMatches(dow, dowStr, 0, 6, CRON_DOW_NAMES)
269
317
  ) {
270
318
  return cursor;
271
319
  }