pi-better-goal 0.10.0 → 0.10.2
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 +7 -1
- package/package.json +1 -1
- package/src/index.ts +40 -5
- package/src/task-context.ts +50 -0
- package/src/workflow-gates.ts +33 -5
package/README.md
CHANGED
|
@@ -21,6 +21,12 @@ Use `pi-better-goal` when a Pi session should keep an explicit objective visible
|
|
|
21
21
|
- A progress-aware follow-up loop with ten identical no-progress retries and linear backoff (60s through 600s by default), followed by a sticky hold recoverable with `/goal resume` or `alt+g`. Conversation, changed callback results, and background drains do not reopen an exhausted hold. Delays saturate at Node's timer limit, and observed background drains reset non-held progress before callback turns can cancel the wake. Pi's network retry policy remains independent.
|
|
22
22
|
- An observable-progress stall state for active goals.
|
|
23
23
|
|
|
24
|
+
## Optional Harness-Owned Outcome & Todo
|
|
25
|
+
|
|
26
|
+
When `pi-better-harness` is installed, its optional **Outcome & Todo** runtime owns the session's structured outcome and checklist. Goal keeps its own objective, clocks, pause and permission holds, workflow gates, background wake behavior, and continuation policy; it also sends lifecycle requests to the Harness runtime so a bound outcome starts, pauses, resumes, and completes with the Goal. When the runtime is present, Goal stops adding its own active-objective text to avoid duplicating the Harness-owned outcome, whether Outcome & Todo is enabled or off.
|
|
27
|
+
|
|
28
|
+
`/goal` does not install, enable, or configure Outcome & Todo. Install Harness and enable **Outcome & Todo** in `/harness-settings` if you want that shared outcome and checklist. Without Harness, this package keeps its standalone prompts and behavior unchanged. The stored Goal objective remains the raw original trigger (for example, a bound command invocation) so `/goal resume` can re-enter the work; it is not the authoritative business outcome when Harness owns one.
|
|
29
|
+
|
|
24
30
|
## Settings Storage
|
|
25
31
|
|
|
26
32
|
Goal preferences are saved only in Pi's global `~/.pi/agent/settings.json` under
|
|
@@ -59,7 +65,7 @@ After every successful context compaction (Pi's `session_compact` event) while a
|
|
|
59
65
|
|
|
60
66
|
### Scheduler-backed workflow gates
|
|
61
67
|
|
|
62
|
-
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 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`. Every gate
|
|
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.
|
|
63
69
|
|
|
64
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.
|
|
65
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.
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -42,6 +42,7 @@ import { collectSubagentActivity } from "./subagents.js";
|
|
|
42
42
|
import { currentWorkflowOwner, skillCommandName, workflowEntry, workflowOwnerFromSkill, WORKFLOW_ENTRY_TYPE } from "./workflow.js";
|
|
43
43
|
import { completionGate, pauseGate, schedulerWaitUntil } from "./workflow-gates.js";
|
|
44
44
|
import { workflowRehydrateMessage, WORKFLOW_REHYDRATE_MESSAGE_TYPE } from "./rehydrate.js";
|
|
45
|
+
import { requestTaskContext } from "./task-context.js";
|
|
45
46
|
import {
|
|
46
47
|
EVENT_ACTIVITY,
|
|
47
48
|
EVENT_READY,
|
|
@@ -103,11 +104,11 @@ export function pausedGoalStatus(goal: GoalSnapshot, conversationalResume = true
|
|
|
103
104
|
return agentResumable(goal, conversationalResume) ? 'goal paused · say "go" or /goal resume' : "goal paused · /goal resume";
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
function pausedGoalPrompt(goal: GoalSnapshot, conversationalResume: boolean): string {
|
|
107
|
+
function pausedGoalPrompt(goal: GoalSnapshot, conversationalResume: boolean, includeObjective = true): string {
|
|
107
108
|
return [
|
|
108
109
|
goal.pauseReason === "handback"
|
|
109
|
-
? `Pi Better Goal is paused because its workflow handed back unfinished work for the user's answer
|
|
110
|
-
: `Pi Better Goal is paused because the user pressed escape
|
|
110
|
+
? `Pi Better Goal is paused because its workflow handed back unfinished work for the user's answer.${includeObjective ? ` Goal: ${goal.objective}` : ""}`
|
|
111
|
+
: `Pi Better Goal is paused because the user pressed escape.${includeObjective ? ` Goal: ${goal.objective}` : ""}`,
|
|
111
112
|
"Treat the user's messages as ordinary conversation: answer them, but do not continue the goal's work until it is resumed.",
|
|
112
113
|
conversationalResume ? GOAL_RESUME_RULE : "Only the user can resume this goal, with /goal resume or alt+g. A conversational go-ahead does not resume it.",
|
|
113
114
|
].join("\n");
|
|
@@ -407,6 +408,17 @@ export default function (pi: ExtensionAPI): void {
|
|
|
407
408
|
const previous = getGoal(ctx);
|
|
408
409
|
const wasVisible = previous !== null && isGoalClockVisible(previous);
|
|
409
410
|
pi.appendEntry(EXTENSION_NAME, goalSetEntry(goal, source));
|
|
411
|
+
// Bridge lifecycle after the raw Goal entry is durable. Harness may own the
|
|
412
|
+
// structured outcome, but Goal still owns its trigger, clocks, pauses, and wakes.
|
|
413
|
+
if (!previous || previous.goalId !== goal.goalId) {
|
|
414
|
+
requestTaskContext(pi, "start", goal);
|
|
415
|
+
} else if (goal.status === "complete") {
|
|
416
|
+
requestTaskContext(pi, "complete", goal);
|
|
417
|
+
} else if (goal.status === "paused" || goal.status === "budgetLimited") {
|
|
418
|
+
requestTaskContext(pi, "pause", goal);
|
|
419
|
+
} else if (goal.status === "active" && previous.status !== "active") {
|
|
420
|
+
requestTaskContext(pi, "resume", goal);
|
|
421
|
+
}
|
|
410
422
|
continuationQueuedFor = null;
|
|
411
423
|
backgroundDrainTracker = null;
|
|
412
424
|
clearIdleContinuation();
|
|
@@ -422,6 +434,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
422
434
|
lastAgentEvidence = null;
|
|
423
435
|
const current = getGoal(ctx);
|
|
424
436
|
const wasVisible = current !== null && isGoalClockVisible(current);
|
|
437
|
+
// Clearing pauses a Harness-bound outcome; it never deletes or completes it.
|
|
438
|
+
if (current) requestTaskContext(pi, "pause", current);
|
|
425
439
|
pi.appendEntry(EXTENSION_NAME, goalClearEntry(current?.goalId ?? null, source));
|
|
426
440
|
if (getWorkflow(ctx)) recordWorkflow(null);
|
|
427
441
|
continuationQueuedFor = null;
|
|
@@ -868,6 +882,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
868
882
|
}
|
|
869
883
|
const nextRenderDelay = goalClockRefreshDelayMs(goal);
|
|
870
884
|
if (nextRenderDelay !== null) scheduler.schedule(nextRenderDelay);
|
|
885
|
+
// Schedule before handing visual ownership to Harness so its header
|
|
886
|
+
// keeps ticking. An absent display response preserves standalone Goal.
|
|
887
|
+
if (requestTaskContext(pi, "inspect", goal, width).display === true) {
|
|
888
|
+
return [];
|
|
889
|
+
}
|
|
871
890
|
const fg = typeof theme?.fg === "function"
|
|
872
891
|
? (color: string, value: string) => theme.fg(color as never, value)
|
|
873
892
|
: undefined;
|
|
@@ -1318,6 +1337,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1318
1337
|
currentCtx = ctx;
|
|
1319
1338
|
const goal = currentGoalSnapshot(ctx);
|
|
1320
1339
|
const owner = getWorkflow(ctx);
|
|
1340
|
+
// A defined `enabled` means a Harness runtime is present, even when
|
|
1341
|
+
// Outcome & Todo is off. Then Harness owns the structured outcome text.
|
|
1342
|
+
const harnessPresent = requestTaskContext(pi, "inspect", goal).enabled !== undefined;
|
|
1321
1343
|
const snapshot = await publishSnapshot(ctx);
|
|
1322
1344
|
if (!snapshot) return;
|
|
1323
1345
|
const permissions = goal ? currentPermissionHold(ctx, goal.goalId) : null;
|
|
@@ -1330,7 +1352,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1330
1352
|
return { systemPrompt: `${event.systemPrompt}\n\n${permissionPrompt}` };
|
|
1331
1353
|
}
|
|
1332
1354
|
const pausedInstruction = permissionPrompt || (goal?.status === "paused" && (goal.pauseReason === "interrupt" || goal.pauseReason === "handback")
|
|
1333
|
-
? pausedGoalPrompt(goal, preferences.conversationalResume) : "");
|
|
1355
|
+
? pausedGoalPrompt(goal, preferences.conversationalResume, !harnessPresent) : "");
|
|
1334
1356
|
if (!isPokeable(goal) && !owner) {
|
|
1335
1357
|
return pausedInstruction ? { systemPrompt: `${event.systemPrompt}\n\n${pausedInstruction}` } : undefined;
|
|
1336
1358
|
}
|
|
@@ -1363,7 +1385,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1363
1385
|
: "While the goal is paused, this overrides the workflow instructions below: do not advance the workflow until the goal is resumed.\n\n")
|
|
1364
1386
|
: "") +
|
|
1365
1387
|
`Active workflow: ${owner.name} (${owner.path}). Its task plan owns planning and the parent is a coordinator, not a product-code implementer. Follow the workflow instructions below, including on resumed turns:\n\n${instructions}` +
|
|
1366
|
-
(isPokeable(goal)
|
|
1388
|
+
(isPokeable(goal)
|
|
1389
|
+
? harnessPresent
|
|
1390
|
+
? "\n\nComplete the goal only after the workflow completion audit."
|
|
1391
|
+
: `\n\nActive objective: ${goal.objective}. Complete it only after the workflow completion audit.`
|
|
1392
|
+
: "") +
|
|
1367
1393
|
(questionInstruction ? `\n\n${questionInstruction.trim()}` : "") +
|
|
1368
1394
|
(heldInstruction ? `\n\n${heldInstruction}` : ""),
|
|
1369
1395
|
};
|
|
@@ -1373,6 +1399,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1373
1399
|
const backgroundInstruction = snapshot.backgroundRunning
|
|
1374
1400
|
? ` The goal still has delegated background work running (${summarizeActiveBackground(snapshot)}). Foreground idleness alone is not goal completion; keep any structured plan current and do not mark verification, the plan, or the goal complete until every relevant delegated task reaches a terminal state and its result or failure has been inspected and integrated.`
|
|
1375
1401
|
: "";
|
|
1402
|
+
if (harnessPresent) {
|
|
1403
|
+
// Harness owns the outcome text. Retain only the Goal-specific guidance
|
|
1404
|
+
// that the Outcome & Todo prompt cannot replace.
|
|
1405
|
+
const retained = [heldInstruction, backgroundInstruction.trim(), questionInstruction.trim(), permissionPrompt]
|
|
1406
|
+
.filter((part) => part.length > 0);
|
|
1407
|
+
return retained.length > 0
|
|
1408
|
+
? { systemPrompt: `${event.systemPrompt}\n\n${retained.join("\n\n")}` }
|
|
1409
|
+
: undefined;
|
|
1410
|
+
}
|
|
1376
1411
|
return {
|
|
1377
1412
|
systemPrompt:
|
|
1378
1413
|
`${event.systemPrompt}\n\n` +
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { GoalSnapshot } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/** Synchronous request bus shared with an installed `pi-better-harness` runtime. */
|
|
5
|
+
export const TASK_CONTEXT_EVENT = "pi-better-harness:task-context";
|
|
6
|
+
|
|
7
|
+
export type TaskContextAction = "inspect" | "start" | "pause" | "resume" | "complete";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Mutable request. A present Harness runtime sets `enabled` to a boolean even
|
|
11
|
+
* when Outcome & Todo is off. Without Harness the field stays undefined, which
|
|
12
|
+
* lets Goal retain its standalone behavior.
|
|
13
|
+
*/
|
|
14
|
+
export interface TaskContextRequest {
|
|
15
|
+
action: TaskContextAction;
|
|
16
|
+
goalId?: string;
|
|
17
|
+
/** Current widget width on a visual ownership inspection. */
|
|
18
|
+
width?: number;
|
|
19
|
+
/** The raw original goal trigger; only start requests carry it. */
|
|
20
|
+
objective?: string;
|
|
21
|
+
/** Hooks authority: a present Harness runtime sets this even when Outcome & Todo is off. */
|
|
22
|
+
enabled?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Visual ownership. A Harness runtime sets this on `inspect` responses when
|
|
25
|
+
* its combined Todo header is rendering the requested `goalId`. It is
|
|
26
|
+
* independent of `enabled` and stays undefined without Harness, so Goal keeps
|
|
27
|
+
* its standalone clock widget whenever that header does not own the goal.
|
|
28
|
+
*/
|
|
29
|
+
display?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Emit one task-context request and return it so the caller can inspect the
|
|
34
|
+
* Harness-owned `enabled` field after synchronous listeners run.
|
|
35
|
+
*/
|
|
36
|
+
export function requestTaskContext(
|
|
37
|
+
pi: Pick<ExtensionAPI, "events">,
|
|
38
|
+
action: TaskContextAction,
|
|
39
|
+
goal?: GoalSnapshot | null,
|
|
40
|
+
width?: number,
|
|
41
|
+
): TaskContextRequest {
|
|
42
|
+
const request: TaskContextRequest = { action };
|
|
43
|
+
if (width !== undefined) request.width = width;
|
|
44
|
+
if (goal) {
|
|
45
|
+
request.goalId = goal.goalId;
|
|
46
|
+
if (action === "start") request.objective = goal.objective;
|
|
47
|
+
}
|
|
48
|
+
pi.events.emit(TASK_CONTEXT_EVENT, request);
|
|
49
|
+
return request;
|
|
50
|
+
}
|
package/src/workflow-gates.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
3
4
|
import { dirname, join } from "node:path";
|
|
4
5
|
|
|
5
6
|
import type { WorkflowOwner } from "./workflow.js";
|
|
@@ -8,6 +9,8 @@ import type { WorkflowOwner } from "./workflow.js";
|
|
|
8
9
|
const WORKFLOW_PLAN_ENTRY = "pi-better-workflow-plan";
|
|
9
10
|
const WORKFLOW_ENTRY = "pi-better-workflow";
|
|
10
11
|
export const SCHEDULER_TIMEOUT_MS = 20_000;
|
|
12
|
+
// Live tree queries allow three 60-second attempts plus follow-ups; cached probes stay cheap.
|
|
13
|
+
const SCHEDULER_SYNC_TIMEOUT_MS = 240_000;
|
|
11
14
|
const DETAIL_LIMIT = 200;
|
|
12
15
|
|
|
13
16
|
/** Scheduler locations relative to the owner's skill directory, in precedence order. */
|
|
@@ -100,9 +103,13 @@ function parseSchedulerOutput(stdout: string): SchedulerRun {
|
|
|
100
103
|
* are due and 2 on a usage or plan error, so only the latter, a timeout, or bad output is a failure.
|
|
101
104
|
*/
|
|
102
105
|
export function runScheduler(script: string, runDir: string, timeoutMs = SCHEDULER_TIMEOUT_MS): Promise<SchedulerRun> {
|
|
106
|
+
return executeScheduler(script, runDir, timeoutMs);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function executeScheduler(script: string, runDir: string, timeoutMs: number, live = false, cwd = runDir): Promise<SchedulerRun> {
|
|
103
110
|
return new Promise((resolve) => {
|
|
104
|
-
execFile("node", [script, "--run", runDir, "--slots", "1", "--peek", "--json"],
|
|
105
|
-
{ cwd
|
|
111
|
+
execFile("node", [script, "--run", runDir, "--slots", "1", "--peek", "--json", ...(live ? ["--sync"] : [])],
|
|
112
|
+
{ cwd, timeout: timeoutMs, encoding: "utf8", windowsHide: true, maxBuffer: 8 * 1024 * 1024 },
|
|
106
113
|
(error, stdout, stderr) => {
|
|
107
114
|
if (error) {
|
|
108
115
|
// An exit status of 1 means "actions due"; any other failure (exit 2, timeout, spawn error) is real.
|
|
@@ -117,6 +124,27 @@ export function runScheduler(script: string, runDir: string, timeoutMs = SCHEDUL
|
|
|
117
124
|
});
|
|
118
125
|
}
|
|
119
126
|
|
|
127
|
+
async function settlementScheduler(script: string, runDir: string, timeoutMs?: number): Promise<SchedulerRun> {
|
|
128
|
+
const cached = await runScheduler(script, runDir, timeoutMs);
|
|
129
|
+
if (!cached.ok || cached.due.length !== 1 || cached.due[0]!.action !== "sync") return cached;
|
|
130
|
+
|
|
131
|
+
// --peek suppresses seen bookkeeping, not syncRun's issue-tree write. Give the live
|
|
132
|
+
// probe private inputs; keep cwd so relative workspace paths retain their meaning.
|
|
133
|
+
let snapshot: string | undefined;
|
|
134
|
+
try {
|
|
135
|
+
snapshot = mkdtempSync(join(tmpdir(), "pi-goal-scheduler-"));
|
|
136
|
+
for (const name of ["task-plan.json", "issue-tree.json", "next-actions-seen.json", "reviews", "results"]) {
|
|
137
|
+
const source = join(runDir, name);
|
|
138
|
+
if (existsSync(source)) cpSync(source, join(snapshot, name), { recursive: true, dereference: true });
|
|
139
|
+
}
|
|
140
|
+
return await executeScheduler(script, snapshot, timeoutMs ?? SCHEDULER_SYNC_TIMEOUT_MS, true, runDir);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
return { ok: false, reason: `could not prepare the live scheduler check (${String(error).slice(0, DETAIL_LIMIT)})` };
|
|
143
|
+
} finally {
|
|
144
|
+
if (snapshot) rmSync(snapshot, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
120
148
|
interface PlannedRun {
|
|
121
149
|
runDir: string;
|
|
122
150
|
script: string;
|
|
@@ -147,7 +175,7 @@ export async function completionGate(
|
|
|
147
175
|
): Promise<GateVerdict> {
|
|
148
176
|
const located = locateRun(entries, owner);
|
|
149
177
|
if ("warning" in located) return unchecked(located.warning);
|
|
150
|
-
const run = await
|
|
178
|
+
const run = await settlementScheduler(located.script, located.runDir, options.timeoutMs);
|
|
151
179
|
if (!run.ok) return unchecked(`could not check the ${owner.name} run (${run.reason})`);
|
|
152
180
|
const open = run.due.filter((action) => action.action !== "finish");
|
|
153
181
|
if (open.length === 0 && run.due.length > 0) return { allow: true };
|
|
@@ -190,7 +218,7 @@ export async function pauseGate(
|
|
|
190
218
|
if ("warning" in located) return unchecked(located.warning);
|
|
191
219
|
const units = readUnits(located.runDir);
|
|
192
220
|
if (!units) return unchecked(`could not read the ${owner.name} task plan`);
|
|
193
|
-
const run = await
|
|
221
|
+
const run = await settlementScheduler(located.script, located.runDir, options.timeoutMs);
|
|
194
222
|
if (!run.ok) return unchecked(`could not check the ${owner.name} run (${run.reason})`);
|
|
195
223
|
|
|
196
224
|
const refusals: string[] = [];
|