pi-better-harness 0.40.5 → 0.41.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.
Files changed (36) hide show
  1. package/README.md +1 -0
  2. package/extensions/project-context/index.ts +20 -0
  3. package/extensions/task-context/workflow-plan-update.ts +3 -3
  4. package/extensions/task-context/workflow-plan.ts +22 -6
  5. package/extensions/tool-call-guard/index.ts +79 -0
  6. package/extensions/tool-call-guard/waits.ts +59 -0
  7. package/extensions/worktree-context.ts +157 -0
  8. package/node_modules/pi-better-goal/README.md +5 -3
  9. package/node_modules/pi-better-goal/package.json +1 -1
  10. package/node_modules/pi-better-goal/src/index.ts +2 -2
  11. package/node_modules/pi-better-goal/src/workflow-gates.ts +60 -14
  12. package/node_modules/pi-better-goal/src/workflow.ts +7 -0
  13. package/node_modules/pi-better-subagents/README.md +20 -8
  14. package/node_modules/pi-better-subagents/agent-commands.ts +29 -10
  15. package/node_modules/pi-better-subagents/agent-inspection.ts +116 -5
  16. package/node_modules/pi-better-subagents/agents-catalog-tool.ts +5 -3
  17. package/node_modules/pi-better-subagents/batch.mjs +6 -0
  18. package/node_modules/pi-better-subagents/catalog-resolver.ts +28 -0
  19. package/node_modules/pi-better-subagents/catalog-runtime.ts +8 -0
  20. package/node_modules/pi-better-subagents/catalog-schema.ts +110 -8
  21. package/node_modules/pi-better-subagents/catalog-skill-edits.ts +116 -0
  22. package/node_modules/pi-better-subagents/catalog-ui.ts +172 -27
  23. package/node_modules/pi-better-subagents/child-ownership.ts +7 -1
  24. package/node_modules/pi-better-subagents/config.ts +35 -3
  25. package/node_modules/pi-better-subagents/continuation.ts +3 -0
  26. package/node_modules/pi-better-subagents/docs/agent-catalog-operations.md +47 -4
  27. package/node_modules/pi-better-subagents/docs/agent-catalog.md +12 -1
  28. package/node_modules/pi-better-subagents/docs/usage.md +81 -8
  29. package/node_modules/pi-better-subagents/index.ts +212 -8
  30. package/node_modules/pi-better-subagents/package.json +1 -1
  31. package/node_modules/pi-better-subagents/registry.ts +11 -0
  32. package/node_modules/pi-better-subagents/settings-page.ts +220 -47
  33. package/node_modules/pi-better-subagents/shared-worktree-context.ts +158 -0
  34. package/node_modules/pi-better-subagents/skill-picker.ts +349 -0
  35. package/node_modules/pi-better-subagents/skills.ts +183 -0
  36. package/package.json +6 -4
package/README.md CHANGED
@@ -252,6 +252,7 @@ Use the installer when you want every core extension with standalone package ide
252
252
  | Recommended install | `npx pi-better-harness install` |
253
253
  | Write sandbox on macOS | Seatbelt (`sandbox-exec`), ships with the OS |
254
254
  | Write sandbox on Linux | Bubblewrap — install `bubblewrap` |
255
+ | Linked-worktree project context | A Pi host that applies `systemPromptOptions` after `before_agent_start` (Pi 1.x); older hosts keep the loaded prompt |
255
256
  | Development runtime | Node.js 22+ |
256
257
 
257
258
  ## Update Or Remove
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Load project context from the linked worktree that owns the working directory.
3
+ *
4
+ * A linked worktree is its own project root: its context file is the one Pi
5
+ * should send, without the main checkout's file that ancestor loading also
6
+ * collects. The global context file under the agent directory is kept, because
7
+ * it does not belong to either checkout.
8
+ *
9
+ * Filters only, and returns nothing, so Pi keeps publishing prompt sections and
10
+ * the cached prompt prefix (ADR 0016, ADR 0021).
11
+ */
12
+
13
+ import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
+ import { restrictContextFilesToWorktree } from "../worktree-context.ts";
15
+
16
+ export default function projectContextExtension(pi: ExtensionAPI): void {
17
+ pi.on("before_agent_start", (event) => {
18
+ restrictContextFilesToWorktree(event.systemPromptOptions, getAgentDir());
19
+ });
20
+ }
@@ -52,7 +52,7 @@ const FLEET_STATUS_ALIASES: Record<string, string> = { "n/a": "not-applicable" }
52
52
 
53
53
  const ROW_PROTECTED = new Set(["id"]);
54
54
  const RUN_PROTECTED = new Set([
55
- "runId", "planRevision", "updatedAt", "startedAt", "units", "issues", "components", "fleet", "decisions",
55
+ "runId", "runDir", "planRevision", "updatedAt", "startedAt", "units", "issues", "components", "fleet", "decisions",
56
56
  ]);
57
57
  const PROFILING_RESERVED = new Set([
58
58
  "ts", "runId", "planRevision", "event", "changes", "decision", "scope", "unit", "component", "stage", "logAheadRevision",
@@ -80,7 +80,7 @@ export function applyRushPlanUpdate(
80
80
 
81
81
  const originalText = readFileSync(file, "utf8");
82
82
  const original: unknown = JSON.parse(originalText);
83
- const current = projectRushPlan(original, runId);
83
+ const current = projectRushPlan(original, runId, dirname(file));
84
84
  if (update.revision !== undefined && update.revision !== current.planRevision) {
85
85
  throw new Error(`Rush plan revision mismatch: expected ${update.revision}, found ${current.planRevision}. Read the plan with get_todo and retry.`);
86
86
  }
@@ -117,7 +117,7 @@ export function applyRushPlanUpdate(
117
117
  const revision = current.planRevision + 1;
118
118
  next.planRevision = revision;
119
119
  next.updatedAt = ts;
120
- const projected = projectRushPlan(next, runId);
120
+ const projected = projectRushPlan(next, runId, dirname(file));
121
121
 
122
122
  const extra = update.profiling ?? {};
123
123
  if (!isRecord(extra)) throw new Error("workflow.profiling must be an object of extra event fields.");
@@ -63,15 +63,31 @@ export const MAX_RUSH_PLAN_BYTES = 2_000_000;
63
63
 
64
64
  export function readRushPlan(path: string, cwd: string): RushPlan {
65
65
  const { file, runId } = resolveRushPlanFile(path, cwd);
66
- return projectRushPlan(JSON.parse(readFileSync(file, "utf8")), runId);
66
+ return projectRushPlan(JSON.parse(readFileSync(file, "utf8")), runId, dirname(file));
67
67
  }
68
68
 
69
69
  /** Validate a parsed task-plan.json document and project the fields the plan view shows. */
70
- export function projectRushPlan(data: unknown, runId: string): RushPlan {
71
- if (!isRecord(data) || data.runId !== runId || !Number.isSafeInteger(data.planRevision) ||
72
- (data.planRevision as number) < 0 || !isRecord(data.fleet)) {
70
+ export function projectRushPlan(data: unknown, runId: string, expectedRunDir?: string): RushPlan {
71
+ if (!isRecord(data) || !Number.isSafeInteger(data.planRevision) ||
72
+ (data.planRevision as number) < 0) {
73
73
  throw new Error("Invalid Rush plan identity or revision.");
74
74
  }
75
+ // Current preflight plans carry runDir instead of duplicating its basename
76
+ // in runId. The caller has already confined the file to the run directory.
77
+ const validIdentity = data.runId === undefined
78
+ ? typeof data.runDir === "string" && isAbsolute(data.runDir) && basename(data.runDir) === runId
79
+ : data.runId === runId;
80
+ if (!validIdentity) throw new Error("Invalid Rush plan identity or revision.");
81
+ if (expectedRunDir !== undefined && data.runDir !== undefined) {
82
+ let matches = false;
83
+ try {
84
+ matches = typeof data.runDir === "string" && isAbsolute(data.runDir)
85
+ && realpathSync(data.runDir) === realpathSync(expectedRunDir);
86
+ } catch { /* An unavailable run directory cannot establish identity. */ }
87
+ if (!matches) throw new Error("Invalid Rush plan identity or revision: runDir does not match the plan directory.");
88
+ }
89
+ const persistedFleet = data.fleet === undefined ? {} : data.fleet;
90
+ if (!isRecord(persistedFleet)) throw new Error("Invalid Rush fleet stages.");
75
91
  const persistedUnits = Array.isArray(data.issues) ? data.issues : data.units;
76
92
  if (!Array.isArray(persistedUnits)) throw new Error("Invalid Rush plan units.");
77
93
  const issues = persistedUnits.map((unit: unknown) => {
@@ -90,13 +106,13 @@ export function projectRushPlan(data: unknown, runId: string): RushPlan {
90
106
  };
91
107
  });
92
108
  const fleet: RushPlan["fleet"] = {};
93
- for (const [stage, value] of Object.entries(data.fleet)) {
109
+ for (const [stage, value] of Object.entries(persistedFleet)) {
94
110
  const status = typeof value === "string" ? value : isRecord(value) ? value.status : undefined;
95
111
  if (typeof status !== "string") throw new Error("Invalid Rush fleet stage.");
96
112
  fleet[stage] = { status };
97
113
  }
98
114
  return {
99
- runId: data.runId as string,
115
+ runId,
100
116
  planRevision: data.planRevision as number,
101
117
  issues,
102
118
  fleet,
@@ -0,0 +1,79 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type { SettingsControl } from "../settings/registry.ts";
3
+ import { readHarnessSetting, updateHarnessSetting } from "../shared-harness-settings.ts";
4
+ import { blockReason, detectForegroundWait, type WaitMatch } from "./waits.ts";
5
+
6
+ const SETTING_KEY = "toolCallGuard";
7
+
8
+ interface GuardSetting { version?: unknown; enabled?: unknown }
9
+
10
+ /**
11
+ * Block a foreground command that waits on an external system.
12
+ *
13
+ * Returns the block result from `tool_call`, which pi turns into a failed tool result carrying
14
+ * `reason`. Nothing is executed. `isEnabled` is read per call so the setting takes effect
15
+ * without reloading the extension.
16
+ */
17
+ export function installToolCallGuard(
18
+ pi: ExtensionAPI,
19
+ isEnabled: () => boolean,
20
+ onBlock?: (match: WaitMatch, command: string) => void,
21
+ ): void {
22
+ pi.on("tool_call", (event) => {
23
+ if (!isEnabled()) return;
24
+ if (event.toolName !== "bash") return;
25
+ // `input` is the current SDK field; older typings and some hosts expose `args`.
26
+ const payload = event as { input?: { command?: unknown }; args?: { command?: unknown } };
27
+ const command = payload.input?.command ?? payload.args?.command;
28
+ if (typeof command !== "string") return;
29
+ const match = detectForegroundWait(command);
30
+ if (!match) return;
31
+ onBlock?.(match, command);
32
+ return { block: true, reason: blockReason(match) };
33
+ });
34
+ }
35
+
36
+ export default function toolCallGuardExtension(pi: ExtensionAPI): void {
37
+ let enabled = true;
38
+
39
+ const read = (): void => {
40
+ const value = readHarnessSetting<GuardSetting>(SETTING_KEY);
41
+ if (value !== undefined && (value?.version !== 1 || typeof value.enabled !== "boolean")) {
42
+ throw new Error("Invalid tool-call guard setting in global settings.json.");
43
+ }
44
+ enabled = value?.enabled !== false;
45
+ };
46
+
47
+ const setting: SettingsControl = {
48
+ id: "tool-call-guard", label: "Tool call guard", values: ["On", "Off"],
49
+ get: () => enabled ? "On" : "Off",
50
+ change(value: string, _ctx: ExtensionCommandContext) {
51
+ if (value !== "On" && value !== "Off") throw new Error("Invalid tool-call guard value.");
52
+ updateHarnessSetting(SETTING_KEY, () => ({ version: 1, enabled: value === "On" }));
53
+ enabled = value === "On";
54
+ },
55
+ };
56
+
57
+ const register = () => pi.events.emit("harness-settings:register", setting);
58
+ let stopSettings: (() => void) | undefined;
59
+ const subscribe = () => { stopSettings ??= pi.events.on("harness-settings:request", register); };
60
+
61
+ subscribe();
62
+ register();
63
+ installToolCallGuard(pi, () => enabled);
64
+
65
+ pi.on("session_start", () => {
66
+ subscribe();
67
+ try { read(); }
68
+ catch {
69
+ // A malformed value must not take the session down; fall back to guarding.
70
+ enabled = true;
71
+ }
72
+ register();
73
+ });
74
+
75
+ pi.on("session_shutdown", () => {
76
+ stopSettings?.();
77
+ stopSettings = undefined;
78
+ });
79
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Recognise a foreground command that waits on something outside this machine.
3
+ *
4
+ * The guard keys off command shape rather than duration: a five minute test run belongs in the
5
+ * foreground where its output streams, while `gh pr checks --watch` can only end when GitHub
6
+ * says so. Duration cannot tell those apart, and blocking the first would be wrong.
7
+ */
8
+ export interface WaitMatch {
9
+ /** Which rule matched, quoted back to the caller so the block is explainable. */
10
+ rule: string;
11
+ /** What the command is waiting for. */
12
+ waitsOn: string;
13
+ }
14
+
15
+ /** A foreground pause shorter than this is ordinary command flow, not a wait. */
16
+ export const SLEEP_THRESHOLD_SECONDS = 3;
17
+
18
+ const RULES: readonly { rule: RegExp; waitsOn: string }[] = [
19
+ { rule: /\bgh\s+pr\s+checks\b[^\n]*--watch\b/, waitsOn: "a pull request's checks" },
20
+ { rule: /\bgh\s+run\s+(?:view|watch)\b[^\n]*--watch\b/, waitsOn: "a workflow run" },
21
+ { rule: /\bgh\s+run\s+watch\b/, waitsOn: "a workflow run" },
22
+ { rule: /\bgh\s+workflow\s+run\b[^\n]*--watch\b/, waitsOn: "a workflow run" },
23
+ // A helper script whose name starts with watch- polls an external system, whatever its flags.
24
+ { rule: /\bwatch[-_][\w.-]*/, waitsOn: "an external state change" },
25
+ // --watch and --until only mean polling on provider CLIs; `git log --until` is a plain filter.
26
+ { rule: /\b(?:gh|github|glab)\b[^\n]*--(?:watch|until)\b/, waitsOn: "an external state change" },
27
+ { rule: /(?:^|[;&|]\s*)watch\s+\S/, waitsOn: "a repeating command" },
28
+ { rule: /\btail\s+-[a-zA-Z]*[fF]/, waitsOn: "a growing file" },
29
+ { rule: /\bdocker\s+(?:logs|events)\b[^\n]*\s-f\b/, waitsOn: "a container's log stream" },
30
+ { rule: /\bkubectl\b[^\n]*(?:--watch\b|\s-w\b)/, waitsOn: "cluster state" },
31
+ // A poll loop only counts when its body actually waits on something.
32
+ { rule: /\b(?:until|while)\b[^\n]*\b(?:sleep|curl|gh\s|docker\s|kubectl\s|pg_isready)\b/, waitsOn: "a repeated external check" },
33
+ ];
34
+
35
+ const SLEEP = /(?:^|[;&|(]\s*|\bsudo\s+)sleep\s+(\d+(?:\.\d+)?)/;
36
+
37
+ export function detectForegroundWait(command: string): WaitMatch | undefined {
38
+ for (const { rule, waitsOn } of RULES) {
39
+ if (rule.test(command)) return { rule: rule.source, waitsOn };
40
+ }
41
+ const sleep = SLEEP.exec(command);
42
+ if (sleep) {
43
+ const seconds = Number(sleep[1]);
44
+ if (Number.isFinite(seconds) && seconds > SLEEP_THRESHOLD_SECONDS) {
45
+ return { rule: SLEEP.source, waitsOn: `${seconds} seconds of wall clock` };
46
+ }
47
+ }
48
+ return undefined;
49
+ }
50
+
51
+ /** The message the model reads instead of the command's output. */
52
+ export function blockReason(match: WaitMatch): string {
53
+ return [
54
+ `This command waits on ${match.waitsOn}, so it belongs in a background task.`,
55
+ "Spawn it with bg_task_spawn, then call wait_for_operations to park until it reports; a background task survives the end of the turn, while a foreground wait does not.",
56
+ "Read bg_task_status and bg_task_log for the verdict.",
57
+ 'To run this command in the foreground anyway, set Tool call guard to Off in Harness settings.',
58
+ ].join(" ");
59
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Project context follows the linked worktree that owns the working directory.
3
+ *
4
+ * Pi loads one global context file from the agent directory and one file from
5
+ * every ancestor of the working directory. In a linked worktree (`git worktree
6
+ * add`) those ancestors include the main checkout, so the worktree inherits its
7
+ * instructions in addition to its own. The worktree is the project context root:
8
+ * keep the global file and files at or below the worktree root, drop everything
9
+ * loaded above it. A worktree without its own context file therefore starts
10
+ * without the main checkout's file instead of falling back to it.
11
+ *
12
+ * This module only filters. It never adds a context file or a prompt section, so
13
+ * `--no-context-files` keeps its empty list, and it returns nothing to Pi, so
14
+ * guidance keeps arriving as sections (ADR 0016, ADR 0021). A host that
15
+ * rebuilds the prompt from `systemPromptOptions` after the `before_agent_start`
16
+ * handlers applies the filtered list; on a host that does not rebuild, the
17
+ * mutation is inert rather than a whole-prompt return.
18
+ *
19
+ * Subagent children use a vendored copy of this file
20
+ * (`packages/pi-better-subagents/shared-worktree-context.ts`) because a
21
+ * standalone Subagents install has no Harness package.
22
+ */
23
+
24
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
25
+ import { basename, dirname, join, resolve, sep } from "node:path";
26
+
27
+ /** The subset of a loaded context file this module needs to read. */
28
+ export interface ProjectContextFile {
29
+ path: string;
30
+ }
31
+
32
+ /** The subset of Pi's structured prompt options this module edits. */
33
+ export interface ProjectContextOptions {
34
+ /** Working directory the prompt was built for. Falls back to `process.cwd()`. */
35
+ cwd?: string;
36
+ contextFiles?: ProjectContextFile[];
37
+ }
38
+
39
+ interface GitPaths {
40
+ /** Directory holding the `.git` file or directory found for the working tree. */
41
+ repoDir: string;
42
+ /** Git directory for this working tree (`.git/worktrees/<name>` when linked). */
43
+ gitDir: string;
44
+ /** Git directory holding the shared object database and administrative state. */
45
+ commonGitDir: string;
46
+ }
47
+
48
+ function canonicalizePath(path: string): string {
49
+ try {
50
+ return realpathSync(path);
51
+ } catch {
52
+ return path;
53
+ }
54
+ }
55
+
56
+ /** Compare paths the way the host does: resolved, and case-folded on Windows. */
57
+ function comparisonKey(path: string): string {
58
+ const resolved = resolve(path);
59
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
60
+ }
61
+
62
+ /** True when `child` is `parent` or inside it. */
63
+ function isPathWithin(parent: string, child: string): boolean {
64
+ const parentKey = comparisonKey(parent);
65
+ const childKey = comparisonKey(child);
66
+ if (childKey === parentKey) return true;
67
+ return childKey.startsWith(parentKey.endsWith(sep) ? parentKey : `${parentKey}${sep}`);
68
+ }
69
+
70
+ /** Find the nearest working tree above `startDir` without running Git. */
71
+ function findGitPaths(startDir: string): GitPaths | undefined {
72
+ let dir = resolve(startDir);
73
+ while (true) {
74
+ const gitPath = join(dir, ".git");
75
+ if (existsSync(gitPath)) {
76
+ try {
77
+ const stat = statSync(gitPath);
78
+ if (stat.isFile()) {
79
+ const content = readFileSync(gitPath, "utf8").trim();
80
+ if (!content.startsWith("gitdir: ")) return undefined;
81
+ const gitDir = resolve(dir, content.slice(8).trim());
82
+ if (!existsSync(join(gitDir, "HEAD"))) return undefined;
83
+ // A linked worktree has `commondir`; a submodule's gitdir has none,
84
+ // and keeps ordinary ancestor inheritance.
85
+ const commonDirPath = join(gitDir, "commondir");
86
+ const commonGitDir = existsSync(commonDirPath)
87
+ ? resolve(gitDir, readFileSync(commonDirPath, "utf8").trim())
88
+ : gitDir;
89
+ return { repoDir: dir, gitDir, commonGitDir };
90
+ }
91
+ if (stat.isDirectory()) {
92
+ if (!existsSync(join(gitPath, "HEAD"))) return undefined;
93
+ return { repoDir: dir, gitDir: gitPath, commonGitDir: gitPath };
94
+ }
95
+ } catch {
96
+ return undefined;
97
+ }
98
+ }
99
+ const parent = dirname(dir);
100
+ if (parent === dir) return undefined;
101
+ dir = parent;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * The linked worktree root that owns `cwd`, or undefined for an ordinary
107
+ * checkout. Returned canonicalized, because a worktree's `.git` file records the
108
+ * real path while `cwd` may still be a symlink (macOS `/tmp` → `/private/tmp`).
109
+ */
110
+ export function findLinkedWorktreeRoot(cwd: string): string | undefined {
111
+ const gitPaths = findGitPaths(canonicalizePath(cwd));
112
+ // Only a shared object store makes this a worktree; the main checkout, a
113
+ // separate `.git` directory and a submodule keep ordinary inheritance.
114
+ if (!gitPaths || gitPaths.gitDir === gitPaths.commonGitDir) return undefined;
115
+ return canonicalizePath(gitPaths.repoDir);
116
+ }
117
+
118
+ /**
119
+ * Where Pi selected a context file, canonicalized through its directory only.
120
+ *
121
+ * Resolving the whole path would follow a filename symlink to its target, which
122
+ * inverts both cases that matter: a worktree `AGENTS.md` linking to the main
123
+ * checkout's file would look like inherited context, and the main checkout's
124
+ * `AGENTS.md` linking back into the worktree would look local. The boundary is
125
+ * the location the loader selected, so resolve the containing directory (macOS
126
+ * `/tmp` → `/private/tmp`, a symlinked cwd, a symlinked agent directory) and
127
+ * keep the selected filename itself.
128
+ */
129
+ function selectedContextPath(filePath: string): string {
130
+ return join(canonicalizePath(dirname(filePath)), basename(filePath));
131
+ }
132
+
133
+ /**
134
+ * Drop project context loaded above the linked worktree that owns `cwd`.
135
+ *
136
+ * The global file Pi selected under `agentDir` always survives, even when that
137
+ * filename is a symlink to a file outside the agent directory, because the
138
+ * global selection is not inherited checkout context. Outside a linked worktree
139
+ * this is a no-op, so ordinary repositories keep every ancestor file. A host
140
+ * that hands over no prompt options is left unchanged. Mutates
141
+ * `options.contextFiles` in place because Pi hands the handler its mutable
142
+ * prompt options.
143
+ */
144
+ export function restrictContextFilesToWorktree(options: ProjectContextOptions | undefined, agentDir: string): void {
145
+ if (!options) return;
146
+ const files = options.contextFiles;
147
+ if (!files || files.length === 0) return;
148
+ const worktreeRoot = findLinkedWorktreeRoot(options.cwd ?? process.cwd());
149
+ if (!worktreeRoot) return;
150
+ const allowedRoots = [worktreeRoot, canonicalizePath(agentDir)];
151
+ const kept = files.filter((file) => {
152
+ const path = selectedContextPath(file.path);
153
+ return allowedRoots.some((root) => isPathWithin(root, path));
154
+ });
155
+ if (kept.length === files.length) return;
156
+ options.contextFiles = kept;
157
+ }
@@ -65,10 +65,12 @@ After every successful context compaction (Pi's `session_compact` event) while a
65
65
 
66
66
  ### Scheduler-backed workflow gates
67
67
 
68
- A coordinator that ships the resolve-issues scheduler (`scripts/next-actions.mjs` in its skill directory, or in a sibling `resolve-issues` skill) keeps its Goal from ending while the run still has work. The extension first runs `node next-actions.mjs --run <run dir> --slots 1 --peek --json` (read-only, 20-second timeout), where the run directory is the folder of the task plan bound through `update_plan`. For completion and handback only, if the sole due action is `sync`, it reruns with `--sync` to obtain a fresh issue tree. This live check has a four-minute timeout to accommodate the tree fetcher's network attempts and follow-ups. Because `--peek` suppresses scheduler bookkeeping but not issue-tree cache writes, the live check uses a temporary copy of the scheduler's plan, tree, bookkeeping, review, and result inputs; it does not write the bound run. Heartbeat probes never request live synchronization. A failed or incomplete tree fetch that produces `sync` still refuses completion or handback; cached evidence is not treated as live. Every gate retains its fail-open behavior for execution failures: if the scheduler is missing, errors, times out, prints bad output, or no plan is bound yet, the call proceeds and its result carries a warning, so nothing deadlocks.
68
+ A coordinator that ships the resolve-issues scheduler (`scripts/next-actions.mjs` in its skill directory, or in a sibling `resolve-issues` skill) keeps its Goal from ending while the run still has work. The extension first runs `node next-actions.mjs --run <run dir> --slots 1 --peek --json` (read-only, 20-second timeout), where the run directory is the folder of the task plan bound through `update_todo` with `workflow.path` and `workflow.revision`. For completion and handback only, if the sole due action is `sync`, it reruns with `--sync` to obtain a fresh issue tree. This live check has a four-minute timeout to accommodate the tree fetcher's network attempts and follow-ups. Because `--peek` suppresses scheduler bookkeeping but not issue-tree cache writes, the live check uses a temporary copy of the scheduler's plan, tree, bookkeeping, review, and result inputs; it does not write the bound run. Heartbeat probes never request live synchronization. A failed or incomplete tree fetch that produces `sync` still refuses completion or handback; cached evidence is not treated as live. Output that is not a well-formed due list is a failure, not an empty run: a due entry without an `action` invalidates the whole answer instead of being dropped, so it cannot hide due work.
69
69
 
70
- - **Completion.** `update_goal` complete is refused, with each due action (action, unit, first 200 characters of detail), unless the scheduler reports `finish` and nothing else is due (an empty due list means workers are still running, so it is refused too). The Goal stays active.
71
- - **Pause.** `release_workflow` is refused unless every unfinished unit is quiet (nothing due for it) or blocked `human-decision` with `humanItem` 2-5. A unit blocked `retry-ci` or any other reason the run retries itself, or a `human-decision` item 1, is named in the refusal and the Goal stays active.
70
+ How a gate reports an unverifiable run follows the workflow that owns it. The issue workflows (`rush-issues` and `resolve-issues`) always have a scheduler available (rush-issues through its `resolve-issues` dependency), so only that scheduler can declare their run finished: a missing plan, a missing scheduler, a scheduler error, a timeout, or invalid output refuses completion and leaves the Goal active and the workflow bound. A coordinator that ships no scheduler, such as a release workflow, has no run authority to consult, so its completion gate keeps the fail-open warning that avoids deadlocking it.
71
+
72
+ - **Completion.** `update_goal` complete is refused, with each due action (action, unit, first 200 characters of detail), unless the scheduler reports `finish` and nothing else is due (an empty due list means workers are still running, so it is refused too). An unverifiable issue-workflow run is refused as well, with the step that restores it: bind the task plan with `update_todo({workflow:{path,revision}})`, rebind the run with `/skill:<coordinator>`, or fix the scheduler. The Goal stays active.
73
+ - **Pause.** `release_workflow` is refused unless every unfinished unit is quiet (nothing due for it) or blocked `human-decision` with `humanItem` 2-5. A unit blocked `retry-ci` or any other reason the run retries itself, or a `human-decision` item 1, is named in the refusal and the Goal stays active. Execution failures keep the fail-open warning here, so a genuine human hold can always hand the run back.
72
74
  - **Auto-bind.** Invoking `/skill:rush-issues` or `/skill:resolve-issues` with no active goal (none, or a completed one) creates and activates a Goal whose objective is the invocation text, exactly as `/goal <invocation>` would, without sending the command a second time. A paused or active Goal is left alone.
73
75
  - **Wait timer.** When the scheduler's only due action is `wait` with a "next automatic attempt is at <ISO time>" detail, the next automatic continuation is scheduled for that time (at most the maximum timer delay, at least 5 seconds) and the wake-up is not counted as a no-progress retry. Other due work, or an unparseable time, uses the ordinary retry schedule. `autoContinue` off disables this timer.
74
76
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.12.1",
3
+ "version": "0.12.2",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -42,7 +42,7 @@ import {
42
42
  import { createRenderScheduler } from "./shared-render-scheduler.js";
43
43
  import { initializeNavigatorPanels, setNavigatorPanel } from "./shared-navigator-panels.js";
44
44
  import { collectSubagentActivity } from "./subagents.js";
45
- import { currentWorkflowOwner, skillCommandName, workflowEntry, workflowOwnerFromSkill, WORKFLOW_ENTRY_TYPE } from "./workflow.js";
45
+ import { currentWorkflowOwner, ISSUE_WORKFLOW_SKILLS, skillCommandName, workflowEntry, workflowOwnerFromSkill, WORKFLOW_ENTRY_TYPE } from "./workflow.js";
46
46
  import { completionGate, pauseGate, schedulerWaitUntil, operationResumeSchedulerGate } from "./workflow-gates.js";
47
47
  import { workflowRehydrateMessage, WORKFLOW_REHYDRATE_MESSAGE_TYPE } from "./rehydrate.js";
48
48
  import { requestTaskContext } from "./task-context.js";
@@ -66,7 +66,7 @@ const MAX_TIMER_DELAY_MS = 2_147_483_647;
66
66
  /** Never re-poll a workflow's scheduler sooner than this, even when its stated attempt time has passed. */
67
67
  const MIN_WAIT_DELAY_MS = 5_000;
68
68
  /** Workflow skills whose invocation binds a goal when none is active, so the run cannot stop silently. */
69
- const AUTO_BIND_SKILLS = new Set(["rush-issues", "resolve-issues"]);
69
+ const AUTO_BIND_SKILLS = ISSUE_WORKFLOW_SKILLS;
70
70
  const WAKE_DISABLED =
71
71
  process.env.PI_BETTER_GOAL_DISABLE_WAKE === "1" ||
72
72
  process.env.PI_BETTER_EXTENSION_DISABLE_WAKE === "1";
@@ -3,7 +3,7 @@ import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync } from
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
 
6
- import type { WorkflowOwner } from "./workflow.js";
6
+ import { ISSUE_WORKFLOW_SKILLS, type WorkflowOwner } from "./workflow.js";
7
7
 
8
8
  /** Branch entry the plan extension writes when it binds a workflow-owned task plan. */
9
9
  const WORKFLOW_PLAN_ENTRY = "pi-better-workflow-plan";
@@ -88,13 +88,18 @@ function parseSchedulerOutput(stdout: string): SchedulerRun {
88
88
  return { ok: false, reason: "the scheduler printed output that is not JSON" };
89
89
  }
90
90
  if (!isRecord(parsed) || !Array.isArray(parsed.due)) return { ok: false, reason: "the scheduler output has no due list" };
91
- const due = parsed.due.flatMap((item): SchedulerAction[] => isRecord(item) && typeof item.action === "string"
92
- ? [{
91
+ const due: SchedulerAction[] = [];
92
+ for (const item of parsed.due) {
93
+ // A malformed entry could hide due work, so it invalidates the answer instead of being dropped.
94
+ if (!isRecord(item) || typeof item.action !== "string") {
95
+ return { ok: false, reason: "the scheduler output has a due entry without an action" };
96
+ }
97
+ due.push({
93
98
  action: item.action,
94
99
  unit: item.unit === null || item.unit === undefined ? null : String(item.unit),
95
100
  detail: typeof item.detail === "string" ? item.detail : "",
96
- }]
97
- : []);
101
+ });
102
+ }
98
103
  return { ok: true, due };
99
104
  }
100
105
 
@@ -114,6 +119,10 @@ function executeScheduler(script: string, runDir: string, timeoutMs: number, liv
114
119
  if (error) {
115
120
  // An exit status of 1 means "actions due"; any other failure (exit 2, timeout, spawn error) is real.
116
121
  if ((error as { code?: unknown }).code !== 1) {
122
+ if ((error as { killed?: unknown }).killed === true) {
123
+ resolve({ ok: false, reason: `the scheduler timed out after ${timeoutMs} ms` });
124
+ return;
125
+ }
117
126
  const detail = String(stderr).trim().split("\n")[0] || error.message;
118
127
  resolve({ ok: false, reason: `the scheduler failed (${detail.slice(0, DETAIL_LIMIT)})` });
119
128
  return;
@@ -150,11 +159,14 @@ interface PlannedRun {
150
159
  script: string;
151
160
  }
152
161
 
153
- function locateRun(entries: Iterable<EntryLike>, owner: WorkflowOwner): PlannedRun | { warning: string } {
162
+ /** The run a gate would check, or what it could not locate, so each gate decides how to report it. */
163
+ type RunLocation = PlannedRun | { missing: "scheduler" | "plan"; problem: string };
164
+
165
+ function locateRun(entries: Iterable<EntryLike>, owner: WorkflowOwner): RunLocation {
154
166
  const script = findScheduler(owner);
155
- if (!script) return { warning: `${owner.name} ships no scheduler, so the run was not checked` };
167
+ if (!script) return { missing: "scheduler", problem: `${owner.name} ships no scheduler` };
156
168
  const plan = boundPlanPath(entries, owner);
157
- if (!plan) return { warning: `no task plan is bound for ${owner.name} yet, so the run was not checked` };
169
+ if (!plan) return { missing: "plan", problem: `no task plan is bound for ${owner.name} yet` };
158
170
  return { runDir: dirname(plan), script };
159
171
  }
160
172
 
@@ -167,16 +179,50 @@ function describe(action: SchedulerAction): string {
167
179
  return `- ${action.action}${action.unit ? ` (unit ${action.unit})` : ""}: ${detail}`;
168
180
  }
169
181
 
170
- /** Completion is allowed only when the scheduler reports `finish` and nothing else is due. */
182
+ /** Coordinators whose runs the resolve-issues scheduler drives, so only it can declare the run finished. */
183
+ function schedulerOwnsCompletion(owner: WorkflowOwner): boolean {
184
+ return ISSUE_WORKFLOW_SKILLS.has(owner.name);
185
+ }
186
+
187
+ function unverifiedCompletion(owner: WorkflowOwner, problem: string, recovery: string): GateVerdict {
188
+ return {
189
+ allow: false,
190
+ due: [],
191
+ message: `Goal not marked complete: ${problem}, so the run's completion is not verified. ${recovery} ` +
192
+ `The ${owner.name} workflow stays bound and the Goal stays active.`,
193
+ };
194
+ }
195
+
196
+ /**
197
+ * Completion is allowed only when the scheduler reports `finish` and nothing else is due. For the
198
+ * issue workflows an unavailable plan or scheduler never counts as permission: the run has a
199
+ * completion authority, and only that authority may declare it finished. Any other coordinator has
200
+ * no scheduler to consult, so its gate keeps the fail-open warning that avoids deadlocking it.
201
+ */
171
202
  export async function completionGate(
172
203
  entries: Iterable<EntryLike>,
173
204
  owner: WorkflowOwner,
174
205
  options: { timeoutMs?: number } = {},
175
206
  ): Promise<GateVerdict> {
207
+ const owned = schedulerOwnsCompletion(owner);
176
208
  const located = locateRun(entries, owner);
177
- if ("warning" in located) return unchecked(located.warning);
209
+ if ("missing" in located) {
210
+ if (!owned) return unchecked(`${located.problem}, so the run was not checked`);
211
+ const recovery = located.missing === "plan"
212
+ ? `Bind the run's task plan with update_todo({workflow:{path,revision}}), or rebind the run with /skill:${owner.name}, ` +
213
+ "then call update_goal again once the scheduler reports finish."
214
+ : `Restore the run's scheduler or rebind the run with /skill:${owner.name}, ` +
215
+ "then call update_goal again once the scheduler reports finish.";
216
+ return unverifiedCompletion(owner, located.problem, recovery);
217
+ }
178
218
  const run = await settlementScheduler(located.script, located.runDir, options.timeoutMs);
179
- if (!run.ok) return unchecked(`could not check the ${owner.name} run (${run.reason})`);
219
+ if (!run.ok) {
220
+ const problem = `the ${owner.name} run could not be checked (${run.reason})`;
221
+ if (!owned) return unchecked(problem);
222
+ return unverifiedCompletion(owner, problem,
223
+ `Fix the scheduler, or rebind the run with /skill:${owner.name} if its plan moved, ` +
224
+ "then call update_goal again once the scheduler reports finish.");
225
+ }
180
226
  const open = run.due.filter((action) => action.action !== "finish");
181
227
  if (open.length === 0 && run.due.length > 0) return { allow: true };
182
228
  if (open.length === 0) {
@@ -215,7 +261,7 @@ export async function pauseGate(
215
261
  options: { timeoutMs?: number } = {},
216
262
  ): Promise<GateVerdict> {
217
263
  const located = locateRun(entries, owner);
218
- if ("warning" in located) return unchecked(located.warning);
264
+ if ("missing" in located) return unchecked(`${located.problem}, so the run was not checked`);
219
265
  const units = readUnits(located.runDir);
220
266
  if (!units) return unchecked(`could not read the ${owner.name} task plan`);
221
267
  const run = await settlementScheduler(located.script, located.runDir, options.timeoutMs);
@@ -271,7 +317,7 @@ export async function operationResumeSchedulerGate(
271
317
  options: { timeoutMs?: number } = {},
272
318
  ): Promise<OperationResumeSchedulerGate> {
273
319
  const located = locateRun(entries, owner);
274
- if ("warning" in located) return { ok: false, reason: located.warning };
320
+ if ("missing" in located) return { ok: false, reason: `${located.problem}, so the run was not checked` };
275
321
  const run = await runScheduler(located.script, located.runDir, options.timeoutMs);
276
322
  if (!run.ok) return { ok: false, reason: `Workflow admission evidence unavailable: ${run.reason}` };
277
323
  return { ok: true, waitUntil: waitUntil(run.due) };
@@ -284,7 +330,7 @@ export async function schedulerWaitUntil(
284
330
  options: { timeoutMs?: number } = {},
285
331
  ): Promise<number | undefined> {
286
332
  const located = locateRun(entries, owner);
287
- if ("warning" in located) return undefined;
333
+ if ("missing" in located) return undefined;
288
334
  const run = await runScheduler(located.script, located.runDir, options.timeoutMs);
289
335
  return run.ok ? waitUntil(run.due) : undefined;
290
336
  }
@@ -3,6 +3,13 @@ import { parse } from "yaml";
3
3
 
4
4
  export const WORKFLOW_ENTRY_TYPE = "pi-better-workflow";
5
5
 
6
+ /**
7
+ * Coordinators whose runs the resolve-issues scheduler drives, so only its next-actions scheduler can
8
+ * declare the run finished. Other coordinators (a release workflow, say) ship no scheduler, and their
9
+ * completion gates keep the fail-open behavior that avoids deadlocking them.
10
+ */
11
+ export const ISSUE_WORKFLOW_SKILLS: ReadonlySet<string> = new Set(["rush-issues", "resolve-issues"]);
12
+
6
13
  export interface WorkflowOwner {
7
14
  name: string;
8
15
  path: string;