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 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 fails open: 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_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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
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. Goal: ${goal.objective}`
110
- : `Pi Better Goal is paused because the user pressed escape. Goal: ${goal.objective}`,
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) ? `\n\nActive objective: ${goal.objective}. Complete it only after the workflow completion audit.` : "") +
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
+ }
@@ -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: runDir, timeout: timeoutMs, encoding: "utf8", windowsHide: true, maxBuffer: 8 * 1024 * 1024 },
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 runScheduler(located.script, located.runDir, options.timeoutMs);
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 runScheduler(located.script, located.runDir, options.timeoutMs);
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[] = [];