@dieulc/workflow 0.8.0 → 0.9.0

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/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@dieulc/workflow",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "keywords": ["pi-package", "workflow", "plan-mode", "review-mode", "questionnaire", "rewind"],
7
7
  "description": "Plan↔Build mode with an independent Review Mode reviewer, questionnaire, subagent explore, todos and shadow-checkpoint rewind for Pi.",
8
8
  "license": "MIT",
9
9
  "scripts": {
10
- "test": "node --test utils.todo.test.ts permissions.test.ts roles.test.ts profiles.test.ts picker.test.ts questionnaire-layout.test.ts questionnaire-view.test.ts model-default.test.ts review.test.ts review-ui.test.ts commit-policy.test.ts edit-policy.test.ts tab-policy.test.ts tab-editor.test.ts",
10
+ "test": "node --test utils.todo.test.ts permissions.test.ts roles.test.ts profiles.test.ts thinking-levels.test.ts workflow-status.test.ts picker.test.ts questionnaire-layout.test.ts questionnaire-view.test.ts model-default.test.ts review.test.ts review-ui.test.ts extension.test.ts commit-policy.test.ts edit-policy.test.ts tab-policy.test.ts tab-editor.test.ts",
11
11
  "typecheck": "tsc --noEmit -p tsconfig.json",
12
12
  "check:policy": "node --experimental-transform-types scripts/verify-write-policy.mjs"
13
13
  },
package/review-runtime.ts CHANGED
@@ -532,6 +532,10 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
532
532
  const remaining = (): number | undefined =>
533
533
  deadline === undefined ? undefined : Math.max(1, deadline - Date.now());
534
534
  let sendError: unknown;
535
+ // The losing waiters in the pass race must be cancelled when the winner
536
+ // settles. Otherwise their timers (up to `timeoutMs`, 5 minutes by
537
+ // default) stay referenced and keep the process alive after the round.
538
+ const waitAbort = new AbortController();
535
539
 
536
540
  const stop = await new Promise<PassStop>((resolve) => {
537
541
  let done = false;
@@ -542,6 +546,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
542
546
  done = true;
543
547
  if (timer !== undefined) clearTimeout(timer);
544
548
  rc.signal.removeEventListener("abort", onAbort);
549
+ waitAbort.abort();
545
550
  resolve(s);
546
551
  };
547
552
  if (rc.signal.aborted) {
@@ -551,10 +556,10 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
551
556
  const cap = remaining();
552
557
  if (cap !== undefined) timer = setTimeout(() => finish("timeout"), cap);
553
558
  rc.signal.addEventListener("abort", onAbort, { once: true });
554
- void gate.waitForPassDone(remaining(), rc.signal).then((result) => {
559
+ void gate.waitForPassDone(remaining(), waitAbort.signal).then((result) => {
555
560
  if (result) finish("passDone");
556
561
  });
557
- void waitForSettle(baseline, remaining(), rc.signal).then((did) => {
562
+ void waitForSettle(baseline, remaining(), waitAbort.signal).then((did) => {
558
563
  if (did) finish("settled");
559
564
  });
560
565
  void send().catch((e: unknown) => {
package/utils.ts CHANGED
@@ -934,22 +934,27 @@ export function isPlanWritePath(p: string, cwd: string): boolean {
934
934
  /** Inputs for deciding whether this run may hand a plan off. */
935
935
  export interface PlanHandoffEligibility {
936
936
  mode: "plan" | "build" | null;
937
- /** The run started in Plan mode (not a Build turn that flipped to Plan). */
938
- runStartedInPlan: boolean;
939
937
  /** This run wrote/edited a file under `.pi/plans/`. */
940
938
  planWrittenThisRun: boolean;
939
+ /**
940
+ * The write happened while Plan mode was active. A run that starts in
941
+ * Default/Build and switches to Plan mid-run counts; a plan written before
942
+ * the switch does not. `agent_end`'s current-run message scan sets this too
943
+ * when the run ends in Plan (the tool_result handler was missed).
944
+ */
945
+ planWrittenInPlanMode: boolean;
941
946
  }
942
947
 
943
948
  /**
944
949
  * A handoff requires all three: the workflow is in Plan mode NOW, the run
945
- * started in Plan mode, and the run actually wrote a plan. Session-lifetime
950
+ * wrote a plan, and that write happened under Plan mode. Session-lifetime
946
951
  * plan files and old todos never authorize a handoff.
947
952
  */
948
953
  export function shouldHandoffPlan(input: PlanHandoffEligibility): boolean {
949
954
  return (
950
955
  input.mode === "plan" &&
951
- input.runStartedInPlan === true &&
952
- input.planWrittenThisRun === true
956
+ input.planWrittenThisRun === true &&
957
+ input.planWrittenInPlanMode === true
953
958
  );
954
959
  }
955
960
 
@@ -978,6 +983,73 @@ export function findPlanWritePathInMessages(
978
983
  return undefined;
979
984
  }
980
985
 
986
+ /** Where a manual `/review` found the plan it is about to audit. */
987
+ export type PlanPathSource =
988
+ | "explicit"
989
+ | "run"
990
+ | "session"
991
+ | "handoff"
992
+ | "branch";
993
+
994
+ export interface PlanPathCandidate {
995
+ path?: string | null;
996
+ source: PlanPathSource;
997
+ }
998
+
999
+ export interface PlanPathSelection {
1000
+ path: string;
1001
+ source: PlanPathSource;
1002
+ }
1003
+
1004
+ /**
1005
+ * Pick the first readable plan for a manual `/review`.
1006
+ *
1007
+ * Candidates arrive in priority order (`explicit` → `run` → `session` →
1008
+ * `handoff` → `branch`). Missing, empty and unreadable candidates are skipped;
1009
+ * a plan that disappeared since it was written must never win over the next
1010
+ * candidate. Pure: the caller owns path resolution and the filesystem probe.
1011
+ */
1012
+ export function choosePlanPath(
1013
+ candidates: readonly PlanPathCandidate[],
1014
+ isReadable: (absolutePath: string) => boolean,
1015
+ ): PlanPathSelection | undefined {
1016
+ for (const candidate of candidates) {
1017
+ const p = candidate.path;
1018
+ if (typeof p !== "string" || p.length === 0) continue;
1019
+ if (isReadable(p)) return { path: p, source: candidate.source };
1020
+ }
1021
+ return undefined;
1022
+ }
1023
+
1024
+ /**
1025
+ * Newest plan reference in a session branch, used as the last manual
1026
+ * `/review` fallback after a reload or when the in-memory path was lost.
1027
+ *
1028
+ * Recognises the persisted `workflow` entry (`planFile`), the
1029
+ * `workflow-handoff-resolve` entry (`planPath`), and an assistant `write`/
1030
+ * `edit` tool call under `.pi/plans/`. Scans newest-first and never throws.
1031
+ */
1032
+ export function findLatestPlanWriteInBranch(
1033
+ entries: readonly unknown[],
1034
+ cwd: string,
1035
+ ): string | undefined {
1036
+ if (!Array.isArray(entries) || entries.length === 0) return undefined;
1037
+ for (let i = entries.length - 1; i >= 0; i--) {
1038
+ const e = entries[i] as any;
1039
+ if (!e || typeof e !== "object") continue;
1040
+ if (e.type === "custom") {
1041
+ const p = e.data?.planPath ?? e.data?.planFile;
1042
+ if (typeof p === "string" && p && isPlanWritePath(p, cwd)) return p;
1043
+ continue;
1044
+ }
1045
+ if (e.type === "message" && e.message) {
1046
+ const found = findPlanWritePathInMessages([e.message], cwd);
1047
+ if (found) return found;
1048
+ }
1049
+ }
1050
+ return undefined;
1051
+ }
1052
+
981
1053
  // ── Plan date helpers (UTC) ──────────────────────────────────────
982
1054
 
983
1055
  /** Return YYYY-MM-DD in UTC for the given date (default: now). Uses toISOString slice, deterministic across locales. */