pi-goal-x 0.31.5 → 0.31.7

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
@@ -6,7 +6,7 @@
6
6
  <a href="https://pi.dev/packages?type=extension" target="_blank" rel="noopener noreferrer">
7
7
  <picture>
8
8
  <source media="(prefers-color-scheme: dark)" srcset="assets/badge-dark.svg">
9
- <img src="assets/badge-light.svg" alt="TOP 0.3% of Pi coding agent extensions: #7 of 3,352 by downloads · Sep 16, 2026 (best recorded rank)" width="480">
9
+ <img src="assets/badge-light.svg" alt="TOP 0.3% of Pi coding agent extensions: #7 of 3,275 by downloads · Sep 22, 2026 (best recorded rank)" width="480">
10
10
  </picture>
11
11
  </a>
12
12
  </div>
@@ -23,6 +23,8 @@ The extension saves goal objectives, tasks, and progress across sessions. You ca
23
23
  pi install npm:pi-goal-x
24
24
  ```
25
25
 
26
+ Supported Pi versions: **0.83–0.87**. The extension supports Node 22.15+; newer Pi releases require Node 22.19+.
27
+
26
28
  ## Create a goal
27
29
 
28
30
  ```text
@@ -120,6 +122,7 @@ Open `/goal-settings` to change these options. You can save defaults for all pro
120
122
  | Task tracking (`disableTasks`) | Turn task lists on or off. Set to `true` to disable them. |
121
123
  | Subtask depth (`subtaskDepth`) | Limit how many levels of subtasks the agent can create. |
122
124
  | Completion requirements (`disableContracts`) | Turn explicit goal and task completion requirements on or off. Set to `true` to disable them. |
125
+ | Unfocused reminder (`hideUnfocusedPrompt`) | When a session has no focused goal but open goals exist in the selected pool, the agent receives a `[PI GOAL UNFOCUSED]` reminder on every request. Set to `true` to stop that reminder. Defaults to `false`. This is independent of `hideUnfocusedBanner`, which hides only the unfocused widget and status hint. Neither setting selects or resumes a goal; the separate `autoSelectSingleGoal` setting still applies. |
123
126
  | Auditor disabled | Turn off independent completion review. |
124
127
  | Auditor provider, model, and thinking level | Choose which model reviews completed work and its reasoning effort. |
125
128
 
@@ -165,6 +168,50 @@ pi.events.emit("pi-goal:wake", { goalId, waitToken });
165
168
 
166
169
  Existing producers that directly send `triggerTurn`/`followUp` messages still run as ordinary host work and supersede old pending decisions. Those independently started turns are **outside this extension's allowance**; use `pi-goal:wake` to put them through its spending gate. The allowance also does not limit Pi's own within-run tool loop or native retries. It bounds the goal extension's kickoff, continuation, check, signal, repair and recovery dispatches.
167
170
 
171
+ ### Prompt caching
172
+
173
+ Goal state is refreshed at the request tail while the system prompt and conversation prefix stay stable. Pi retains control of provider cache settings. See [prompt caching](docs/prompt-caching.md) for explicit-cache handling, validation, and cache invalidation boundaries.
174
+
175
+ ### Changing a token budget
176
+
177
+ Use the existing tweak flow: `/goal-tweak remove the token budget` or `/goal-tweak set the token budget to 50000`. The proposal shows the current and proposed limits before confirmation. A budget is a total lifetime limit, not an additional allocation; consumed tokens and completed work are preserved. Omitting a budget change retains the current limit.
178
+
179
+ After confirmation, a goal stopped only by its budget can continue if the revised limit allows it and scheduling permits. A still-exhausted budget keeps it stopped. Other-session ownership, interrupted execution and exhausted autonomous-run allowances still require their existing recovery steps. Creation and tweak results always show the effective budget.
180
+
181
+ ### Optional arguments on Responses-compatible providers
182
+
183
+ Some Pi `openai-responses` configurations omit the wire-level `strict` flag. OpenAI Responses may normalize schemas into strict mode when that flag is omitted. This can conflict with optional goal arguments; it is separate from pi-goal-x's `strictExecutionContract` scheduling setting.
184
+
185
+ For the reported OpenCode model, a narrowly scoped Pi `models.json` override makes the supported Pi 0.84.1 adapter send `strict: false` for ordinary tools:
186
+
187
+ ```json
188
+ {
189
+ "providers": {
190
+ "opencode": {
191
+ "modelOverrides": {
192
+ "gpt-6-astra": { "compat": { "supportsStrictMode": true } }
193
+ }
194
+ }
195
+ }
196
+ }
197
+ ```
198
+
199
+ The capability flag permits the adapter to send the explicit non-strict opt-out; it does not request strict sampling for ordinary goal tools. Merge this into existing model configuration and reload Pi. This is a provider-specific workaround, not a guarantee about third-party model behavior. The local reproduction inspects requests before transmission; the issue's live OpenCode A/B result has not been independently reproduced. See [issue #59](https://github.com/tmonk/pi-goal-x/issues/59) and [OpenAI's function-calling documentation](https://developers.openai.com/api/docs/guides/function-calling).
200
+
201
+ ### Sharing a goal pool across worktrees
202
+
203
+ By default goals stay in `<cwd>/.pi/goals`. Set `goalsRoot` in your project or global settings to an absolute directory (or `~/path`), or set `PI_GOAL_ROOT` for the session. Precedence is environment, project, global, then the existing default. For example:
204
+
205
+ ```json
206
+ { "goalsRoot": "~/work/project-goals" }
207
+ ```
208
+
209
+ Worktrees pointing to the same root share goals, archives, ledger and locks; focus remains session-local and execution ownership still requires explicit resume. The working directory for tools and audits is unchanged. Existing goals are not moved automatically. Reload/reopen the session after changing roots; `/goal-refresh` refreshes the selected pool. `/goal-status verbose` shows the effective location. Saved goal paths remain logical `.pi/goals/...` paths within that selected pool.
210
+
211
+ `hideUnfocusedPrompt: true` suppresses ordinary unfocused reminders to the model independently of `hideUnfocusedBanner`; neither changes focus or bypasses stale-checkpoint checks.
212
+
213
+ Token usage shown in the dashboard is cumulative across goal turns. The model receives a separate context snapshot when Pi can supply one; unavailable context is never reported as zero. Retained snapshots are bounded and newer snapshots supersede older ones.
214
+
168
215
  ## License
169
216
 
170
217
  MIT
@@ -1,4 +1,4 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="480" height="54" viewBox="0 0 480 54" role="img" aria-label="TOP 0.3% of Pi coding agent extensions: #7 of 3,352 by downloads · Sep 16, 2026 (best recorded rank)">
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="480" height="54" viewBox="0 0 480 54" role="img" aria-label="TOP 0.3% of Pi coding agent extensions: #7 of 3,275 by downloads · Sep 22, 2026 (best recorded rank)">
2
2
  <!-- Dark mode -->
3
3
  <style>
4
4
  .frame { fill: #14181f; stroke: #2a3040; stroke-width: 1; }
@@ -13,6 +13,6 @@
13
13
  <text class="rank" x="18" y="34">TOP 0.3%</text>
14
14
  <line class="divider" x1="128" y1="13" x2="128" y2="41"/>
15
15
  <text class="label" x="143" y="25">of Pi coding agent extensions</text>
16
- <text class="meta" x="143" y="41">#7 of 3,352 by downloads · Sep 16, 2026</text>
16
+ <text class="meta" x="143" y="41">#7 of 3,275 by downloads · Sep 22, 2026</text>
17
17
  </a>
18
18
  </svg>
@@ -1,4 +1,4 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="480" height="54" viewBox="0 0 480 54" role="img" aria-label="TOP 0.3% of Pi coding agent extensions: #7 of 3,352 by downloads · Sep 16, 2026 (best recorded rank)">
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="480" height="54" viewBox="0 0 480 54" role="img" aria-label="TOP 0.3% of Pi coding agent extensions: #7 of 3,275 by downloads · Sep 22, 2026 (best recorded rank)">
2
2
  <!-- Light mode -->
3
3
  <style>
4
4
  .frame { fill: #f6f8fa; stroke: #d0d7de; stroke-width: 1; }
@@ -13,6 +13,6 @@
13
13
  <text class="rank" x="18" y="34">TOP 0.3%</text>
14
14
  <line class="divider" x1="128" y1="13" x2="128" y2="41"/>
15
15
  <text class="label" x="143" y="25">of Pi coding agent extensions</text>
16
- <text class="meta" x="143" y="41">#7 of 3,352 by downloads · Sep 16, 2026</text>
16
+ <text class="meta" x="143" y="41">#7 of 3,275 by downloads · Sep 22, 2026</text>
17
17
  </a>
18
18
  </svg>
@@ -19,7 +19,7 @@ handlers from their dedicated modules:
19
19
  | `goal-task-tools.ts` | `set_goal_tasks` / `update_goal_task` executors plus flat parent-linked conversion, id-stable merge, `countTasks` |
20
20
  | `goal-task-confirmation.ts` | Task-only result boundary (`{decision}`, no auditor toggle) with neutral Confirm task list / Keep current tasks labels |
21
21
  | `goal-commands.ts` | The curated fourteen-command palette and its handlers |
22
- | `goal-events.ts` | The 14 lifecycle event handlers (`context`, `turn_start`, `tool_call`, `tool_execution_end`, `turn_end`, `message_end`, `session_start`, `session_before_compact`, `session_compact`, `session_tree`, `before_agent_start`, `agent_end`, `agent_settled`, `session_shutdown`) |
22
+ | `goal-events.ts` | Lifecycle and provider event handlers (`context`, `before_provider_request`, `turn_start`, `tool_call`, `tool_execution_end`, `turn_end`, `message_end`, `session_start`, `session_before_compact`, `session_compact`, `session_tree`, `before_agent_start`, `agent_end`, `agent_settled`, `session_shutdown`) |
23
23
  | `goal-widget.ts` | Terminal input keybindings (Esc pause / abort-audit, Ctrl+Shift+T overlay) and the hidden debug helpers |
24
24
  | `goal-format.ts` | Pure formatting/message-introspection helpers and renderers |
25
25
  | `goal-service.ts` | `GoalService` — the sole mutation boundary: ordered reconcile → id/focus-revision validation → clone-mutate → write/archive → ledger → memory commit → returned effects |
@@ -341,3 +341,5 @@ operable; `/goal-clear` confirms; task confirmation uses neutral labels;
341
341
  completion commits are failure-checked; and cross-process mutations are
342
342
  serialized with persisted revisions plus per-goal filesystem locks that
343
343
  return typed conflicts to stale writers instead of overwriting blindly.
344
+
345
+ Prompt layout and explicit provider cache handling are documented in [prompt caching](prompt-caching.md). Live goal state is constructed in the context hook; before_agent_start performs lifecycle preflight without modifying the system prompt.
@@ -0,0 +1,27 @@
1
+ # Prompt caching
2
+
3
+ pi-goal-x preserves the provider's reusable prompt prefix during normal user turns, autonomous checkpoints, and tool loops. No extra extension setting is required. Pi's provider configuration still determines whether caching is enabled, its retention period, and model support.
4
+
5
+ The request layout is:
6
+
7
+ 1. Host system prompt and tool schemas.
8
+ 2. Conversation history, with each goal checkpoint normalized to a tiny, stable trigger in its original position.
9
+ 3. Request-only goal policy plus compact counter/context snapshots, replayed at verified history anchors when safe. Current policy includes the objective, tasks, lifecycle rules, limits, scheduling state and applicable recovery/auditor reminders.
10
+
11
+ Changing goal state never rewrites the system prompt or historical messages. The full goal-state message is not persisted into the session. Old checkpoints remain until Pi compacts history; retaining small markers avoids shifting the prefix on every continuation. Display-only auditor messages are consistently excluded, and tool calls stay adjacent to their results.
12
+
13
+ For Anthropic-style explicit caching, the extension moves Pi's existing cache_control breakpoint from the transient state message onto the preceding cacheable history block. The Bedrock equivalent moves the cachePoint. Both preserve the configured TTL and do not add breakpoints. Payloads without an existing explicit marker—including caching disabled and implicit-cache providers—receive no marker changes. The extension does not set cache keys, enable paid extended retention, or replace provider defaults.
14
+
15
+ Execution tools already use an idempotent profile: goal progress, focus, and lifecycle changes do not change schemas. Explicit switches into/out of drafting and changes to disableTasks can change tools and legitimately rebuild the cache. Drafting messages are appended to conversation history. Isolated auditor and Oracle sessions already use fixed instructions/tool profiles and place goal-specific evidence in their user messages; their tool loops retain history. They have separate sessions and use Pi's cache defaults. Opting these sessions into project resources also opts into any behavior from those resources.
16
+
17
+ Cache reuse can still be interrupted by Pi compaction, branch/session changes, model/provider switches, other extensions changing earlier content, changed tool schemas, provider expiry, minimum-length or breakpoint-lookback limits, or routing. The extension cannot guarantee a hit rate or a particular cost reduction. The supported Pi range is 0.83 through 0.87. Validation covers each supported minor; development dependencies pin 0.87.0.
18
+
19
+ Validation includes consecutive request-prefix regressions; actual Anthropic, Anthropic-compatible Chat Completions, OpenAI Chat Completions and Responses serialization with short/long/disabled retention; Bedrock payload-shape checks; lifecycle/tool-pair safety tests; and full SDK parent/auditor/Oracle payload captures. These tests stop before HTTP dispatch. They verify request construction, not live provider hit rates.
20
+
21
+ Provider references: [Anthropic prompt caching](https://platform.claude.com/docs/en/build-with-claude/prompt-caching) describes exact-prefix matching and explicit breakpoints; [DeepSeek context caching](https://api-docs.deepseek.com/guides/kv_cache/) describes its automatic prefix cache and usage counters.
22
+
23
+ Active requests split policy from compact counter/context snapshots. Already-sent tails are replayed at verified history anchors so implicit-cache requests extend their previous prefix. Policy changes (including budget/run-limit removal), rewritten history and unsafe tool-result boundaries reset retention. At most 32 tails and 4 KiB of counter text are retained per session, across at most 16 session/model identities; no full-state tails are persisted. Unchanged requests add no tail. Older snapshots are historical, and current policy takes precedence over cache reuse.
24
+
25
+ Explicit-marker relocation skips all retained live text and tolerates Pi's trailing effort-only messages. Another extension's content-bearing suffix is left alone. Changing models/providers starts a separate retention identity. Unknown session identities do not retain history between requests.
26
+
27
+ Validation now includes advancing SDK request chains, combined effort-marker/retention regressions, 1,000-turn growth bounds and a 64 KiB–4 MiB history-cost gate. These are request-construction and CPU measurements, not live provider hit-rate claims.
@@ -90,3 +90,18 @@ export function budgetLine(goal: BudgetLike): string | null {
90
90
  const used = Math.floor(goal.usage.tokensUsed);
91
91
  return `token budget: ${used}/${Math.floor(goal.tokenBudget ?? 0)} used, ${remaining} remaining`;
92
92
  }
93
+
94
+ /** Agent-facing accounting must never read as current context occupancy. */
95
+ export function modelBudgetLine(goal: BudgetLike): string | null {
96
+ const line = budgetLine(goal);
97
+ return line ? `Lifetime ${line} (spending cap, not context capacity)` : null;
98
+ }
99
+
100
+ export interface GoalContextUsage { tokens: number | null; contextWindow: number }
101
+
102
+ /** A point-in-time observation; older retained observations are historical. */
103
+ export function contextUsageLine(usage?: GoalContextUsage): string {
104
+ if (!usage || usage.tokens === null || !Number.isFinite(usage.tokens) || usage.tokens < 0
105
+ || !Number.isFinite(usage.contextWindow) || usage.contextWindow <= 0) return "Context snapshot: unavailable";
106
+ return `Context snapshot: ${Math.round(usage.tokens)}/${Math.round(usage.contextWindow)} tokens (${Math.round(100 * usage.tokens / usage.contextWindow)}%)`;
107
+ }
@@ -160,7 +160,7 @@ function minimalGoalMetadata(goal: GoalRecord): string {
160
160
  `Goal id: ${goal.id}`,
161
161
  `Status: ${statusLabel(goal)}`,
162
162
  `Mode: ${goal.sisyphus ? "sisyphus" : "regular"}`,
163
- goal.tokenBudget ? `Budget: ${goal.tokenBudget} tokens (${goal.usage.tokensUsed} used)` : undefined,
163
+ goal.tokenBudget ? `Lifetime spending cap: ${goal.tokenBudget} tokens (${goal.usage.tokensUsed} cumulatively used; not context occupancy)` : undefined,
164
164
  ].filter(Boolean).join("\n");
165
165
  }
166
166
 
@@ -177,7 +177,7 @@ export function buildGoalAuditorPrompt(args: {
177
177
  "You are the independent completion auditor for pi-goal. Decide whether the user's objective is actually satisfied.",
178
178
  "Audit checklist:",
179
179
  "1. Extract the real success criteria, including every explicit requirement and quality/reader outcome. Disapprove missing, contradicted, weakly verified or uninspectable requirements.",
180
- "2. Inspect real artifacts with read/grep/find/ls/bash as needed. Do not mutate files or run destructive commands. Paperwork, intent, file/word counts, build success and plausible summaries alone are not proof.",
180
+ "2. Inspect real artifacts with read/grep/find/ls/bash as needed. Never mutate files or clean runtime metadata (.pi goals/subagents). Report environment failures separately. Paperwork, counts and build success alone are not proof.",
181
181
  ...(!args.settings?.disableContracts && args.goal.verificationContract?.trim()
182
182
  ? ["3. Verify that the executor has satisfied every item in the <verification_contract>. If any item is missing or weakly addressed, disapprove."] : []),
183
183
  "4. Explain missing or weak evidence concisely. Disapprove alpha scaffold, generated template, shallow draft or proxy milestones lacking the user-facing value requested.",
@@ -1,3 +1,4 @@
1
+ import { goalStoragePath, goalStorageRoot } from "./storage/goal-root.ts";
1
2
  import { existsSync } from "node:fs";
2
3
  import path from "node:path";
3
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -197,9 +198,9 @@ export function registerGoalCommands(core: GoalCore): void {
197
198
  }
198
199
 
199
200
  async function runGoalRecovery(rawArgs: string, ctx: ExtensionContext): Promise<void> {
200
- const report = runRecoveryReport({ cwd: ctx.cwd });
201
+ const report = runRecoveryReport(ctx);
201
202
  if (/^repair$/i.test(rawArgs)) {
202
- const result = await runRecoveryRepair({ cwd: ctx.cwd }, report, async () => {
203
+ const result = await runRecoveryRepair(ctx, report, async () => {
203
204
  const confirmed = await ctx.ui.confirm(`Remove ${report.staleLocks.length} stale lock(s) and refresh the pool snapshot?`, `Files are backed up to .pi/goals/.recovery-backup first.`);
204
205
  return confirmed === true;
205
206
  });
@@ -287,7 +288,7 @@ export function registerGoalCommands(core: GoalCore): void {
287
288
  verbose,
288
289
  health,
289
290
  activeFilePresent: health && view?.activePath
290
- ? existsSync(path.resolve(ctx.cwd, view.activePath))
291
+ ? existsSync(goalStoragePath(ctx, view.activePath))
291
292
  : undefined,
292
293
  // Issue #30: checkpoint growth report (health view only, read-only).
293
294
  checkpointHealth: health
@@ -298,7 +299,7 @@ export function registerGoalCommands(core: GoalCore): void {
298
299
  checkpointSessionFile: (ctx.sessionManager as { getSessionFile?: () => string | undefined } | undefined)?.getSessionFile?.(),
299
300
  // §13.2: effective settings with provenance appear only in verbose mode;
300
301
  // the standard mode stays free of settings noise (§13.1).
301
- settingsReport: verbose ? effectiveSettingsReport(ctx.cwd) : [],
302
+ settingsReport: verbose ? [...effectiveSettingsReport(ctx.cwd), `effective goal pool: ${goalStorageRoot(ctx)}`] : [],
302
303
  });
303
304
  ctx.ui.notify(text, "info");
304
305
  core.updateUI(ctx);
@@ -369,6 +370,7 @@ export function registerGoalCommands(core: GoalCore): void {
369
370
  const SETTING_ROWS: readonly SettingRow[] = [
370
371
  { key: "autoSelectSingleGoal", label: "autoSelectSingleGoal", section: "Goal behavior", kind: "boolean" },
371
372
  { key: "hideUnfocusedBanner", label: "hideUnfocusedBanner", section: "Goal behavior", kind: "boolean" },
373
+ { key: "hideUnfocusedPrompt", label: "hideUnfocusedPrompt", section: "Goal behavior", kind: "boolean" },
372
374
  { key: "disableContracts", label: "disableContracts", section: "Goal behavior", kind: "boolean" },
373
375
  { key: "strictExecutionContract", label: "explicit execution contracts (opt-in)", section: "Goal behavior", kind: "boolean" },
374
376
  { key: "maxAutonomousRuns", label: "autonomous run allowance", section: "Goal behavior", kind: "positiveInteger" },
@@ -390,7 +392,7 @@ export function registerGoalCommands(core: GoalCore): void {
390
392
  ];
391
393
 
392
394
  function settingsValue(config: GoalSettings, key: keyof GoalSettings | string): string {
393
- if (key === "strictExecutionContract" || key === "disabled" || key === "disableTasks" || key === "disableContracts" || key === "autoSelectSingleGoal" || key === "auditorProjectResources" || key === "hideUnfocusedBanner") {
395
+ if (key === "strictExecutionContract" || key === "disabled" || key === "disableTasks" || key === "disableContracts" || key === "autoSelectSingleGoal" || key === "auditorProjectResources" || key === "hideUnfocusedBanner" || key === "hideUnfocusedPrompt") {
394
396
  return config[key] === true ? "true" : "false";
395
397
  }
396
398
  if (key === "subtaskDepth") return config.subtaskDepth !== undefined ? String(config.subtaskDepth) : "1";
@@ -697,13 +699,13 @@ export function registerGoalCommands(core: GoalCore): void {
697
699
  }
698
700
  const trimmed = replacement.trim();
699
701
  if (!trimmed) {
700
- ctx.ui.notify("Provide the replacement objective: /goal-tweak <new objective>", "info");
702
+ ctx.ui.notify("Describe the requested change: /goal-tweak <change>, for example remove the token budget", "info");
701
703
  return;
702
704
  }
703
705
  const max = loadGoalSettings(ctx.cwd).objectiveMaxChars;
704
706
  if (trimmed.length > (max ?? 0)) {
705
707
  if (max !== undefined && max > 0) {
706
- ctx.ui.notify(`Replacement objective exceeds ${max} characters (${trimmed.length}).`, "warning");
708
+ ctx.ui.notify(`Requested change exceeds ${max} characters (${trimmed.length}).`, "warning");
707
709
  return;
708
710
  }
709
711
  }
@@ -788,7 +790,7 @@ export function registerGoalCommands(core: GoalCore): void {
788
790
  },
789
791
  });
790
792
  pi.registerCommand("goal-tweak", {
791
- description: "Refine the current goal's objective with the user.",
793
+ description: "Revise the current goal or its token budget with confirmation.",
792
794
  handler: async (rawArgs, ctx) => {
793
795
  await runGoalTweak(rawArgs, ctx);
794
796
  },
@@ -23,7 +23,7 @@ export function buildGoalCompactSummary(
23
23
  lines.push(`Goal ${goal.id} — ${statusLabel(goal)}`);
24
24
  lines.push(` Objective: ${truncateText(goal.objective, 200)}`);
25
25
  if (goal.usage.tokensUsed > 0) {
26
- lines.push(` Usage: ${formatTokenValue(goal.usage.tokensUsed)}`);
26
+ lines.push(` Cumulative goal usage (not context occupancy): ${formatTokenValue(goal.usage.tokensUsed)}`);
27
27
  }
28
28
  if (goal.usage.activeSeconds > 0) {
29
29
  lines.push(` Time: ${formatDuration(goal.usage.activeSeconds)}`);
@@ -9,7 +9,7 @@ import {
9
9
  import { loadGoalSettings, loadGoalSettingsFileConfig } from "./goal-settings.ts";
10
10
  import { runGoalCompletionAuditor } from "./goal-auditor.ts";
11
11
  import { nowIso, type GoalRecord } from "./goal-record.ts";
12
- import { latestEventsForGoal, goalRuntimeEvents } from "./goal-ledger.ts";
12
+ import { latestEventsForGoal, latestAuditorResultForGoal, goalRuntimeEvents } from "./goal-ledger.ts";
13
13
  import { mergeGoalPromptFromDisk } from "./storage/goal-files.ts";
14
14
  import { showEscapeDialog, type EscapeDialogResult } from "./widgets/goal-escape-dialog.ts";
15
15
  import type { GoalCore } from "./goal-state.ts";
@@ -242,10 +242,13 @@ if (settings.disabled === true) {
242
242
  // (recent lifecycle + task evidence) so it does not re-derive session facts.
243
243
  const ledger = goalRuntimeEvents(ctx, auditTarget.id);
244
244
  const warmTail = latestEventsForGoal(ledger, auditTarget.id, 8);
245
- const warmContext = warmTail.length > 0
245
+ const previousAudit = latestAuditorResultForGoal(ledger, auditTarget.id);
246
+ let warmContext = warmTail.length > 0
246
247
  ? `Recent goal events (from the shared ledger):\n${warmTail.map((e) => `- ${e.at} ${e.type}${"taskId" in e ? ` (task ${e.taskId})` : ""}${"evidence" in e && e.evidence ? ` evidence: ${e.evidence}` : ""}`).join("\n")}`
247
248
  : null;
248
249
 
250
+ if (previousAudit?.verdict === "disapproved") warmContext = `${warmContext ?? ""}\nPrevious rejection (verify whether resolved): ${previousAudit.report.slice(0, 600)}`;
251
+
249
252
  const auditor = await (core.dependencies.runCompletionAuditor ?? runGoalCompletionAuditor)({
250
253
  ctx,
251
254
  goal: auditTarget,
@@ -7,7 +7,7 @@ import { Text } from "@earendil-works/pi-tui";
7
7
  import { formatDuration, formatTokenValue, statusLabel, truncateText } from "./goal-core.ts";
8
8
  import { extractVerificationContract } from "./goal-contract.ts";
9
9
  import { detailedSummary, goalDetails, renderGoalResult } from "./goal-format.ts";
10
- import { budgetLine } from "./goal-accounting.ts";
10
+ import { modelBudgetLine } from "./goal-accounting.ts";
11
11
  import { buildGoalCreatedReport, buildTaskSummary, findTaskInTree, validateGoalAgentPause, validateGoalBlock } from "./goal-policy.ts";
12
12
  import { buildUnfocusedOpenGoalsSummary, otherOpenGoalCount } from "./goal-pool.ts";
13
13
  import { readGoalLedger, goalOracleState } from "./goal-ledger.ts";
@@ -99,8 +99,8 @@ pi.registerTool(defineTool({
99
99
  const usageBits: string[] = [];
100
100
  if (view.usage.activeSeconds > 0) usageBits.push(formatDuration(view.usage.activeSeconds));
101
101
  if (view.usage.tokensUsed > 0) usageBits.push(formatTokenValue(view.usage.tokensUsed));
102
- lines.push(`Usage: ${usageBits.length > 0 ? usageBits.join(" · ") : "none"}`);
103
- const budget = budgetLine(view);
102
+ lines.push(`Cumulative goal usage (not context occupancy): ${usageBits.length > 0 ? usageBits.join(" · ") : "none"}`);
103
+ const budget = modelBudgetLine(view);
104
104
  if (budget) lines.push(`Budget: ${budget}`);
105
105
  if (view.taskList) {
106
106
  lines.push(`Tasks: ${buildTaskSummary(view.taskList)}`);
@@ -145,7 +145,7 @@ pi.registerTool(defineTool({
145
145
  } else if (view.currentTaskId) {
146
146
  lines.push(`Current task: ${view.currentTaskId}`);
147
147
  }
148
- const budget = budgetLine(view);
148
+ const budget = modelBudgetLine(view);
149
149
  if (budget) lines.push(`Budget: ${budget}`);
150
150
  if ((view.status === "paused" || view.status === "blocked") && view.pauseReason) {
151
151
  lines.push(`Blocker: ${view.pauseReason}`);
@@ -223,7 +223,7 @@ pi.registerTool(defineTool({
223
223
  ? `\n\nThe objective contains ${derived.length} ordered step${derived.length === 1 ? "" : "s"}; propose them as the task tree with set_goal_tasks if the user wants tracked milestones.`
224
224
  : "";
225
225
  return {
226
- content: [{ type: "text", text: `${buildGoalCreatedReport({ objective: created?.objective ?? objective, detailedSummary: detailedSummary(created) })}${bootstrapLine}${otherLine}` }],
226
+ content: [{ type: "text", text: `${buildGoalCreatedReport({ objective: created?.objective ?? objective, detailedSummary: detailedSummary(created), tokenBudget: created?.tokenBudget })}${bootstrapLine}${otherLine}` }],
227
227
  details: goalDetails(created),
228
228
  terminate: true,
229
229
  };
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { Type } from "@earendil-works/pi-ai";
2
3
  import { defineTool, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
4
  import { Text } from "@earendil-works/pi-tui";
@@ -6,10 +7,11 @@ import { buildDraftConfirmationText, buildProposalSummary, buildTweakConfirmatio
6
7
  import { renderConfirmationTasks } from "./goal-task-confirmation.ts";
7
8
  import { deriveTasksFromObjective } from "./goal-task-derive.ts";
8
9
  import { goalDetails, renderGoalResult } from "./goal-format.ts";
9
- import { buildGoalCreatedReport } from "./goal-policy.ts";
10
+ import { budgetReached } from "./goal-accounting.ts";
11
+ import { buildGoalCreatedReport, formatGoalBudget } from "./goal-policy.ts";
10
12
  import { loadGoalSettings } from "./goal-settings.ts";
11
13
  import { DIALOG_UNAVAILABLE_HINT, proposalDialogFailureMessage, formatQuestionnaireAnswers, runGoalQuestionnaire, shouldAutoConfirmProposal, showProposalDialog, type GoalQuestionnaireQuestion, type ProposalDecision } from "./goal-questionnaire.ts";
12
- import { currentTaskIdIsPending, nowIso, type GoalRecord, type GoalTaskList } from "./goal-record.ts";
14
+ import { currentTaskIdIsPending, nowIso, validateTokenBudgetInput, type GoalRecord, type GoalTaskList } from "./goal-record.ts";
13
15
  import type { GoalCore } from "./goal-state.ts";
14
16
  import { convertFlatTasks, countTasks, mergeTasksWithExisting, type FlatTaskInput } from "./goal-task-tools.ts";
15
17
  import { PROPOSE_DRAFT_TOOL_NAME, QUESTIONNAIRE_TOOL_NAME, QUESTION_TOOL_NAME } from "./goal-tool-names.ts";
@@ -171,6 +173,8 @@ export async function startGoalDrafting(core: GoalCore, ctx: ExtensionContext, m
171
173
  "[GOAL TWEAK DRAFT]",
172
174
  "The user wants to revise the focused persistent goal. Discuss requirements as needed; do not edit files or start substantive work.",
173
175
  "Propose the complete revised objective with propose_goal_draft. Preserve the current goal mode. Include a complete flat task list when the revision changes a decomposable plan; omit tasks to retain the current list.",
176
+ "For budget-only changes preserve the objective, tasks, mode and auditor. token_budget: positive integer sets the total lifetime limit; null removes it; omit to retain it. Change budgets only when requested. Do not add clarification steps when the request is clear.",
177
+ formatGoalBudget(targetGoal?.tokenBudget),
174
178
  "Current objective:", targetGoal?.objective ?? "(goal no longer available)",
175
179
  "User request:", trimmed || "(ask what they want to change)",
176
180
  ].join("\n") : goalDraftingPrompt(trimmed, mode);
@@ -190,7 +194,7 @@ function proposedTaskList(core: GoalCore, ctx: ExtensionContext, tasks: FlatTask
190
194
  return { ok: true, value: { tasks: converted.tasks, blockCompletion: blockCompletion === true, proposedAt: nowIso() } };
191
195
  }
192
196
 
193
- export function proposalText(draft: ActiveGoalDraft, objective: string, autoContinue: boolean, taskList: GoalTaskList | undefined, current?: GoalRecord): string {
197
+ export function proposalText(draft: ActiveGoalDraft, objective: string, autoContinue: boolean, taskList: GoalTaskList | undefined, current?: GoalRecord, tokenBudget?: number | null): string {
194
198
  const base = draft.mode === "tweak" && current
195
199
  ? buildTweakConfirmationText({ currentObjective: current.objective, newObjective: objective, changeSummary: draft.originalTopic || "Goal revised through guided drafting.", sisyphus: current.sisyphus, tasks: taskList?.tasks })
196
200
  : buildDraftConfirmationText({ focus: draft.mode === "sisyphus" ? "sisyphus" : "goal", originalTopic: draft.originalTopic, objective, autoContinue });
@@ -219,7 +223,7 @@ export function proposalText(draft: ActiveGoalDraft, objective: string, autoCont
219
223
  tasksText = "\n\nTasks derived from the objective (confirm or ask the agent to adjust):\n" + renderConfirmationTasks(derived, 0).join("\n");
220
224
  }
221
225
  }
222
- return base + tasksText;
226
+ return `${draft.mode === "tweak" ? `Current ${formatGoalBudget(current?.tokenBudget)}\nProposed ${formatGoalBudget(tokenBudget === undefined ? current?.tokenBudget : tokenBudget ?? undefined)}` : formatGoalBudget(tokenBudget ?? undefined)}\n\n${base}${tasksText}`;
223
227
  }
224
228
 
225
229
  function flatTaskSchema() {
@@ -310,7 +314,8 @@ export function registerDraftingTools(core: GoalCore): void {
310
314
  "Confirmation creates or revises the goal atomically. Continue Chatting leaves drafting active for refinement.",
311
315
  ],
312
316
  parameters: Type.Object({
313
- objective: Type.String({ description: "Complete proposed objective, including criteria and constraints." }),
317
+ objective: Type.String({ description: "Complete proposed objective; preserve it exactly for budget-only tweaks." }),
318
+ token_budget: Type.Optional(Type.Union([Type.Integer({minimum: 1}), Type.Null()], {description: "Only on user request: positive whole-token lifetime limit; null removes it. Omit to retain the current tweak budget or create without one."})),
314
319
  auto_continue: Type.Optional(Type.Boolean({ description: "Defaults to true." })),
315
320
  sisyphus: Type.Optional(Type.Boolean({ description: "Must match the user-selected goal mode." })),
316
321
  tasks: Type.Optional(flatTaskSchema()),
@@ -320,6 +325,10 @@ export function registerDraftingTools(core: GoalCore): void {
320
325
  async execute(_id, params, _signal, _update, ctx) {
321
326
  const draft = activeDraft(core);
322
327
  if (!draft) return { content: [{ type: "text", text: "No guided goal draft is active. Do not create a goal without the user starting /goal or /sisyphus." }], details: goalDetails(core.state.goal) };
328
+ if (params.token_budget !== undefined && params.token_budget !== null) {
329
+ const validation = validateTokenBudgetInput(params.token_budget);
330
+ if (!validation.ok) return {content: [{type: "text", text: validation.message}], details: goalDetails(core.state.goal)};
331
+ }
323
332
  const objective = params.objective.trim();
324
333
  if (!objective) return { content: [{ type: "text", text: "The proposed objective must be at least 1 character." }], details: goalDetails(core.state.goal) };
325
334
  const objectiveMaxChars = loadGoalSettings(ctx.cwd).objectiveMaxChars ?? 0;
@@ -328,9 +337,16 @@ export function registerDraftingTools(core: GoalCore): void {
328
337
  if (draft.mode !== "tweak" && ((params.sisyphus === true) !== expectedSisyphus)) return { content: [{ type: "text", text: "Proposal mode does not match the command that began this draft." }], details: goalDetails(core.state.goal) };
329
338
  const taskResult = proposedTaskList(core, ctx, params.tasks as FlatTaskInput[] | undefined, params.block_completion);
330
339
  if (!taskResult.ok) return { content: [{ type: "text", text: taskResult.message }], details: goalDetails(core.state.goal) };
340
+ const flushError = core.goalService.flushForAudit(ctx);
341
+ if (flushError || core.goalService.isTurnBuffered()) return {content: [{type: "text", text: "Proposal not saved: " + (flushError ?? "goal changes are still buffered")}], details: goalDetails(core.state.goal)};
331
342
  core.reconcileFocusedGoalFromDisk(ctx);
332
343
  const target = draft.mode === "tweak" ? core.state.goal : undefined;
333
344
  if (draft.mode === "tweak" && (!target || target.id !== draft.targetGoalId)) return { content: [{ type: "text", text: "The goal changed while drafting; review it and start /goal-tweak again." }], details: goalDetails(core.state.goal) };
345
+ if (target?.status === "complete") return {content: [{type: "text", text: "Completed goals cannot be tweaked."}], details: goalDetails(target)};
346
+ const proposalToken = target ? core.focusedOperationToken(target.id) : undefined;
347
+ const proposalRevision = target?.revision ?? 0;
348
+ const proposedBudget = params.token_budget === undefined ? target?.tokenBudget : params.token_budget ?? undefined;
349
+ const budgetSummary = target ? `Current ${formatGoalBudget(target.tokenBudget)}\nProposed ${formatGoalBudget(proposedBudget)}` : formatGoalBudget(proposedBudget);
334
350
  if (draft.mode === "sisyphus" && !sisyphusObjectiveSufficient(objective)) return { content: [{ type: "text", text: "A Sisyphus goal needs ordered steps with explicit per-step done criteria. Refine the objective with numbered steps (1) ..., 2) ...) or Step N: blocks before proposing again." }], details: goalDetails(core.state.goal) };
335
351
  let confirmation: { decision: ProposalDecision; auditorEnabled: boolean; unavailable: boolean };
336
352
  if (shouldAutoConfirmProposal({ hasUI: ctx.hasUI, autoConfirmEnv: process.env.PI_GOAL_AUTO_CONFIRM })) {
@@ -338,7 +354,7 @@ export function registerDraftingTools(core: GoalCore): void {
338
354
  } else {
339
355
  core.enterGoalModal();
340
356
  try {
341
- confirmation = await showProposalDialog(ctx, proposalText(draft, objective, params.auto_continue !== false, taskResult.value, target ?? undefined), draft.mode === "sisyphus" ? "sisyphus" : "goal", draft.auditorEnabled);
357
+ confirmation = await showProposalDialog(ctx, proposalText(draft, objective, params.auto_continue !== false, taskResult.value, target ?? undefined, params.token_budget), draft.mode === "sisyphus" ? "sisyphus" : "goal", draft.auditorEnabled);
342
358
  } catch (error) {
343
359
  return { content: [{ type: "text", text: `${proposalDialogFailureMessage(error)} Do not retry the dialog until the host issue is resolved.` }], details: goalDetails(core.state.goal) };
344
360
  } finally {
@@ -350,7 +366,7 @@ export function registerDraftingTools(core: GoalCore): void {
350
366
  // §14: the durable proposal summary is part of the transcript for
351
367
  // EVERY outcome (confirm / continue refining / cancel), so the user
352
368
  // can review the proposal after the dialog closes.
353
- const summary = buildProposalSummary({
369
+ const summary = budgetSummary + "\n\n" + buildProposalSummary({
354
370
  objective: extracted.objective,
355
371
  taskList: taskResult.value,
356
372
  verificationContract: extracted.verificationContract,
@@ -385,7 +401,7 @@ export function registerDraftingTools(core: GoalCore): void {
385
401
  const derived = deriveTasksFromObjective(extracted.objective);
386
402
  return derived && derived.length > 0 ? { tasks: derived, blockCompletion: false, proposedAt: nowIso() } : undefined;
387
403
  })();
388
- core.replaceGoal({ objective: extracted.objective, autoContinue: params.auto_continue !== false, sisyphus: expectedSisyphus, taskList: effectiveTaskList, skipAuditor }, ctx, true, extracted.verificationContract);
404
+ core.replaceGoal({ objective: extracted.objective, autoContinue: params.auto_continue !== false, sisyphus: expectedSisyphus, taskList: effectiveTaskList, skipAuditor }, ctx, true, extracted.verificationContract, proposedBudget);
389
405
  clearGoalDrafting(core, ctx);
390
406
  const created = core.state.goal;
391
407
  return { content: [{ type: "text", text: `${summary}\n\n${buildGoalCreatedReport({
@@ -401,7 +417,10 @@ export function registerDraftingTools(core: GoalCore): void {
401
417
  })}` }], details: goalDetails(core.state.goal), terminate: true };
402
418
  }
403
419
  if (!target) return { content: [{ type: "text", text: "The goal changed while drafting; review it and start /goal-tweak again." }], details: goalDetails(core.state.goal) };
404
- const token = core.focusedOperationToken(target.id);
420
+ const token = proposalToken!;
421
+ const finalFlushError = core.goalService.flushForAudit(ctx);
422
+ core.reconcileFocusedGoalFromDisk(ctx);
423
+ if (finalFlushError || core.goalService.isTurnBuffered() || !core.isFocusedOperationCurrent(token) || core.state.goal?.revision !== proposalRevision || core.state.goal?.status === "complete") return {content: [{type: "text", text: "Goal tweak was not applied: the goal changed during confirmation. Review and propose again."}], details: goalDetails(core.state.goal)};
405
424
  const now = nowIso();
406
425
  // §7.5 merge: a goal tweak must never change the status of steps that
407
426
  // persist across the tweak. The proposed list merges into the goal's
@@ -427,16 +446,24 @@ export function registerDraftingTools(core: GoalCore): void {
427
446
  : goal.currentTaskId;
428
447
  // §tweak-resume: resume a paused/blocked goal (mirror the
429
448
  // /goal-resume transition: status active + pause metadata
430
- // cleared); budget_limited stays behind its hard resource
431
- // gate and an active goal stays active.
432
- const stalled = goal.status === "paused" || goal.status === "blocked";
449
+ // cleared). A budget-limited goal needs an explicit budget
450
+ // change that clears the resource gate; preserve scheduler limits.
451
+ const exhausted = budgetReached({...goal, tokenBudget: proposedBudget});
452
+ const wantsResume = goal.status === "paused" || goal.status === "blocked" || (goal.status === "budget_limited" && params.token_budget !== undefined && !exhausted);
453
+ const scheduler = goal.scheduler;
454
+ const owned = !scheduler || (scheduler.owner === (ctx.sessionManager.getSessionId() || "unknown-session") && !["interrupted", "claimed"].includes(scheduler.phase));
455
+ const limit = loadGoalSettings(ctx.cwd).maxAutonomousRuns;
456
+ const allowance = limit === undefined || (scheduler?.used ?? 0) < limit;
457
+ const stalled = wantsResume && !exhausted && owned && allowance;
433
458
  if (stalled) resumed = true;
434
459
  return {
435
460
  ...goal,
461
+ tokenBudget: proposedBudget,
462
+ ...(exhausted && owned && scheduler ? {scheduler: {...scheduler, generation: randomUUID(), phase: "idle" as const, decision: undefined, dispatch: undefined, wait: undefined}} : {}),
436
463
  objective: extracted.objective,
437
464
  verificationContract: extracted.verificationContract ?? goal.verificationContract,
438
465
  taskList, currentTaskId, skipAuditor,
439
- status: stalled ? "active" : goal.status,
466
+ status: exhausted && (goal.status === "active" || goal.status === "budget_limited" || wantsResume) ? "budget_limited" : stalled ? "active" : wantsResume ? "paused" : goal.status,
440
467
  autoContinue: stalled ? true : goal.autoContinue,
441
468
  stopReason: stalled ? undefined : goal.stopReason,
442
469
  pauseReason: stalled ? undefined : goal.pauseReason,
@@ -444,7 +471,7 @@ export function registerDraftingTools(core: GoalCore): void {
444
471
  updatedAt: now,
445
472
  };
446
473
  },
447
- ledger: (written) => [{ type: "goal_tweaked", goalId: written.id, changeSummary: "Goal revised through /goal-tweak drafting.", at: written.updatedAt }, ...(taskResult.value ? [{ type: "task_list_set" as const, goalId: written.id, taskCount: countTasks(written.taskList?.tasks), blockCompletion: taskResult.value.blockCompletion, at: written.updatedAt }] : [])],
474
+ ledger: (written) => [...(target.tokenBudget !== written.tokenBudget ? [{type: "goal_budget_changed" as const, goalId: written.id, oldBudget: target.tokenBudget ?? null, newBudget: written.tokenBudget ?? null, tokensUsed: written.usage.tokensUsed, at: written.updatedAt}] : []), ...(target.status !== "budget_limited" && written.status === "budget_limited" ? [{type: "goal_budget_limited" as const, goalId: written.id, budget: written.tokenBudget!, tokensUsed: written.usage.tokensUsed, at: written.updatedAt}] : []), { type: "goal_tweaked", goalId: written.id, changeSummary: "Goal revised through /goal-tweak drafting.", at: written.updatedAt }, ...(taskResult.value ? [{ type: "task_list_set" as const, goalId: written.id, taskCount: countTasks(written.taskList?.tasks), blockCompletion: taskResult.value.blockCompletion, at: written.updatedAt }] : [])],
448
475
  });
449
476
  if (!result.ok) return { content: [{ type: "text", text: "Goal tweak was not applied: " + result.message }], details: goalDetails(core.state.goal) };
450
477
  if (resumed) {
@@ -455,15 +482,20 @@ export function registerDraftingTools(core: GoalCore): void {
455
482
  }
456
483
  }
457
484
  core.clearContinuationState();
485
+ if (!budgetReached(result.goal)) core.runtime.consumePostBudgetReminder();
486
+ else { core.runtime.armPostBudgetReminder(); core.clearActiveAccounting(); core.scheduler.cancelTimer(); }
458
487
  core.updateUI(ctx);
488
+ let schedulingNote = "";
459
489
  if (resumed) {
460
490
  // Resume glue mirrors replaceGoal: restart accounting and queue
461
491
  // the auto-continuation so the revived goal keeps going.
462
492
  core.beginAccounting();
493
+ const scheduling = core.scheduler.declare(ctx, {kind: "ready", next_action: "Continue the goal after the confirmed tweak."});
494
+ if (!scheduling.terminate) schedulingNote = "\n" + scheduling.content.filter(item => item.type === "text").map(item => item.text).join("\n");
463
495
  core.queueContinuation(ctx, true);
464
496
  }
465
497
  clearGoalDrafting(core, ctx);
466
- return { content: [{ type: "text", text: `${summary}\n\nGoal tweak confirmed and applied.` }], details: goalDetails(result.goal), terminate: true };
498
+ return { content: [{ type: "text", text: `${summary}\n\nGoal tweak confirmed and applied.\n${formatGoalBudget(result.goal.tokenBudget)}${schedulingNote}${result.goal.status === "budget_limited" ? "\nConsumed tokens already meet this budget; the goal remains budget-limited." : result.goal.status === "paused" ? "\nGoal remains paused. Use /goal-resume to continue when scheduling permits." : ""}` }], details: goalDetails(result.goal), terminate: true };
467
499
  },
468
500
  renderCall(args, theme) {
469
501
  // §proposal-presentation: the tool-call display lands in the terminal