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.
- package/README.md +1 -0
- package/extensions/project-context/index.ts +20 -0
- package/extensions/task-context/workflow-plan-update.ts +3 -3
- package/extensions/task-context/workflow-plan.ts +22 -6
- package/extensions/tool-call-guard/index.ts +79 -0
- package/extensions/tool-call-guard/waits.ts +59 -0
- package/extensions/worktree-context.ts +157 -0
- package/node_modules/pi-better-goal/README.md +5 -3
- package/node_modules/pi-better-goal/package.json +1 -1
- package/node_modules/pi-better-goal/src/index.ts +2 -2
- package/node_modules/pi-better-goal/src/workflow-gates.ts +60 -14
- package/node_modules/pi-better-goal/src/workflow.ts +7 -0
- package/node_modules/pi-better-subagents/README.md +20 -8
- package/node_modules/pi-better-subagents/agent-commands.ts +29 -10
- package/node_modules/pi-better-subagents/agent-inspection.ts +116 -5
- package/node_modules/pi-better-subagents/agents-catalog-tool.ts +5 -3
- package/node_modules/pi-better-subagents/batch.mjs +6 -0
- package/node_modules/pi-better-subagents/catalog-resolver.ts +28 -0
- package/node_modules/pi-better-subagents/catalog-runtime.ts +8 -0
- package/node_modules/pi-better-subagents/catalog-schema.ts +110 -8
- package/node_modules/pi-better-subagents/catalog-skill-edits.ts +116 -0
- package/node_modules/pi-better-subagents/catalog-ui.ts +172 -27
- package/node_modules/pi-better-subagents/child-ownership.ts +7 -1
- package/node_modules/pi-better-subagents/config.ts +35 -3
- package/node_modules/pi-better-subagents/continuation.ts +3 -0
- package/node_modules/pi-better-subagents/docs/agent-catalog-operations.md +47 -4
- package/node_modules/pi-better-subagents/docs/agent-catalog.md +12 -1
- package/node_modules/pi-better-subagents/docs/usage.md +81 -8
- package/node_modules/pi-better-subagents/index.ts +212 -8
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/registry.ts +11 -0
- package/node_modules/pi-better-subagents/settings-page.ts +220 -47
- package/node_modules/pi-better-subagents/shared-worktree-context.ts +158 -0
- package/node_modules/pi-better-subagents/skill-picker.ts +349 -0
- package/node_modules/pi-better-subagents/skills.ts +183 -0
- 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) ||
|
|
72
|
-
(data.planRevision as number) < 0
|
|
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(
|
|
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
|
|
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 `
|
|
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
|
-
-
|
|
71
|
-
|
|
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
|
|
|
@@ -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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
167
|
+
if (!script) return { missing: "scheduler", problem: `${owner.name} ships no scheduler` };
|
|
156
168
|
const plan = boundPlanPath(entries, owner);
|
|
157
|
-
if (!plan) return {
|
|
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
|
-
/**
|
|
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 ("
|
|
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)
|
|
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 ("
|
|
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 ("
|
|
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 ("
|
|
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;
|