pi-goal-x 0.10.1 → 0.11.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 CHANGED
@@ -10,10 +10,10 @@ The extension is designed around one rule: **the user owns intent; the agent exe
10
10
 
11
11
  All core features of [@capyup/pi-goal](https://github.com/capyup/pi-goal) are preserved. The following changes are specific to pi-goal-x:
12
12
 
13
- ### Mid-flight objective updates
13
+ ### Goal objective is immutable
14
14
 
15
- - **`update_goal({updatedObjective})`** — the agent can now sync the goal objective mid-flight when user requirements change, *without* completing the goal. This ensures the completion auditor evaluates against the latest requirements. The combined path (`updatedObjective` + `status: "complete"`) applies the update first, then runs the normal completion+audit flow.
16
- - **`apply_goal_tweak`** remains available for `/goal-tweak` drafting revisions; the new parameter is the lightest possible touch on the existing `update_goal` tool.
15
+ - The goal objective is immutable — the agent **must not** modify it autonomously. Objective changes are only possible through `propose_goal_tweak`, which presents the user with a Confirm / Continue Chatting dialog matching the `propose_goal_draft` confirmation pattern. This prevents the agent from silently changing the goal contract.
16
+ - **`propose_goal_tweak`** is the sole mechanism for updating the objective, available exclusively during a `/goal-tweak` drafting flow. If the user's requirements change, they must run `/goal-tweak` to initiate the revision flow.
17
17
 
18
18
  ### Deferred archival
19
19
 
@@ -23,7 +23,7 @@ All core features of [@capyup/pi-goal](https://github.com/capyup/pi-goal) are pr
23
23
  ### E2e test infrastructure
24
24
 
25
25
  - **Deterministic fork tests using `--mode json`**: the e2e suite spawns a real `pi --fork --mode json` session, parses structured `tool_execution_start`/`tool_execution_end` JSON events for field-level assertions — no free-text AI output parsing. Uses `--append-system-prompt` + `--tools` to force deterministic tool calls.
26
- - **Full coverage**: 131 tests total — function-level integration tests (12), mock-pi handler tests (4), file-validity checks (6), and real `pi --fork --mode json` tests (3 scenarios: quick-sync, combined sync+complete, deferred archival).
26
+ - **Full coverage**: 143 tests total — function-level integration tests (12), mock-pi handler tests (4), file-validity checks (6), real `pi --fork --mode json` tests (3 scenarios: quick-sync, combined sync+complete, deferred archival), and propose_goal_tweak unit/integration/e2e tests (15).
27
27
 
28
28
  ### Completion auditor
29
29
 
@@ -156,11 +156,12 @@ The extension exposes tools only when they make sense for the current lifecycle
156
156
  | `goal_question` | drafting / tweak drafting | Ask one focused user question |
157
157
  | `goal_questionnaire` | drafting / tweak drafting | Ask multiple structured questions |
158
158
  | `get_goal` | always | Read the focused goal state; mentions other open goals when present |
159
- | `propose_goal_draft` | registered; accepted only during goal drafting | Submit a concrete draft for user confirmation |
160
- | `apply_goal_tweak` | tweak drafting only | Submit a revision to an existing goal |
159
+ | `propose_goal_draft` | drafting only (goal creation) | Submit a concrete draft for user confirmation |
160
+ | `propose_goal_tweak` | tweak drafting only | Submit a revision to an existing goal (shows Confirm / Continue Chatting dialog) |
161
161
  | `update_goal` | focused active or paused goal | Mark the focused goal complete when all requirements are satisfied. When the auditor is disabled, supply `confirmBypassAuditor: true` after user confirmation to bypass the audit |
162
162
  | `pause_goal` | focused active goal | Pause the focused goal because of a real blocker |
163
163
  | `abort_goal` | focused active or paused goal | Abort/archive an obsolete, impossible, unsafe, or user-cancelled focused goal |
164
+ | `propose_goal_tweak` | tweak drafting only | Submit a revision to the focused goal (shows Confirm / Continue Chatting dialog) |
164
165
  | `step_complete` | hidden / legacy | Compatibility no-op; Sisyphus no longer requires a step counter |
165
166
  | `create_goal` | hidden | Direct calls are rejected; normal creation goes through `propose_goal_draft` |
166
167
 
@@ -227,7 +228,7 @@ The shipped gates are intentionally small and mechanical.
227
228
  | Completion auditor gate | Archiving completion unless an independent pi auditor agent returns `<approved/>` |
228
229
  | Abort gate | Aborting missing, stale, completed, or reasonless goals |
229
230
  | Direct-create rejection | Hidden `create_goal` calls creating goals without the confirmation flow |
230
- | Post-stop block | Continuing to call tools after `pause_goal`, `abort_goal`, `update_goal`, or `apply_goal_tweak` stops the turn |
231
+ | Post-stop block | Continuing to call tools after `pause_goal`, `abort_goal`, `update_goal`, or `propose_goal_tweak` stops the turn |
231
232
  | Empty-turn guard | Pure chat loops that would keep auto-continuing without meaningful goal work |
232
233
  | Abort pause | Active goals staying active after user abort / Ctrl-C |
233
234
  | Disk reconciliation | External pause/archive/delete/status changes being ignored or overwritten by stale memory |
@@ -50,6 +50,33 @@ export function buildDraftConfirmationText(args: {
50
50
  return lines.join("\n");
51
51
  }
52
52
 
53
+ export function buildTweakConfirmationText(args: {
54
+ currentObjective: string;
55
+ newObjective: string;
56
+ changeSummary: string;
57
+ sisyphus: boolean;
58
+ }): string {
59
+ const lines: string[] = [];
60
+ const modeLabel = args.sisyphus ? "Sisyphus (prompt/criteria style)" : "Normal goal";
61
+ lines.push("Goal tweak ready for confirmation.");
62
+ lines.push("");
63
+ lines.push("Draft details:");
64
+ lines.push(`Mode: ${modeLabel}`);
65
+ lines.push("");
66
+ lines.push("Change:");
67
+ lines.push("");
68
+ lines.push(args.changeSummary);
69
+ lines.push("");
70
+ lines.push("Current objective:");
71
+ lines.push("");
72
+ lines.push(args.currentObjective);
73
+ lines.push("");
74
+ lines.push("Proposed new objective:");
75
+ lines.push("");
76
+ lines.push(args.newObjective);
77
+ return lines.join("\n");
78
+ }
79
+
53
80
  export function evaluateDraftingToolGate(args: {
54
81
  toolName: string;
55
82
  draftingFocus?: GoalDraftingFocus | null;
@@ -1,5 +1,5 @@
1
1
  export const SISYPHUS_STEP_TOOL_NAME = "step_complete";
2
- export const TWEAK_APPLY_TOOL_NAME = "apply_goal_tweak";
2
+ export const PROPOSE_TWEAK_TOOL_NAME = "propose_goal_tweak";
3
3
  export const PROPOSE_DRAFT_TOOL_NAME = "propose_goal_draft";
4
4
  export const CREATE_GOAL_TOOL_NAME = "create_goal";
5
5
  export const QUESTION_TOOL_NAME = "goal_question";
@@ -14,7 +14,7 @@ export const GOAL_WORK_TOOL_NAMES = [
14
14
  "update_goal",
15
15
  "pause_goal",
16
16
  ABORT_GOAL_TOOL_NAME,
17
- TWEAK_APPLY_TOOL_NAME,
17
+ PROPOSE_TWEAK_TOOL_NAME,
18
18
  CREATE_GOAL_TOOL_NAME,
19
19
  PROPOSE_DRAFT_TOOL_NAME,
20
20
  QUESTION_TOOL_NAME,
@@ -33,7 +33,6 @@ export const GOAL_PROGRESS_TOOL_NAMES = [
33
33
  "update_goal",
34
34
  "pause_goal",
35
35
  ABORT_GOAL_TOOL_NAME,
36
- TWEAK_APPLY_TOOL_NAME,
37
36
  "write",
38
37
  "edit",
39
38
  "bash",
@@ -10,6 +10,7 @@ import {
10
10
  } from "./goal-core.ts";
11
11
  import {
12
12
  buildDraftConfirmationText,
13
+ buildTweakConfirmationText,
13
14
  goalDraftingPrompt,
14
15
  validateGoalDraftProposal,
15
16
  type GoalDraftingFocus,
@@ -38,7 +39,7 @@ import {
38
39
  SISYPHUS_STEP_TOOL_NAME,
39
40
  GOAL_PROGRESS_TOOL_NAMES,
40
41
  lifecycleToolNamesForGoalStatus,
41
- TWEAK_APPLY_TOOL_NAME,
42
+ PROPOSE_TWEAK_TOOL_NAME,
42
43
  } from "./goal-tool-names.ts";
43
44
  import {
44
45
  asRecord,
@@ -106,7 +107,6 @@ import {
106
107
  shouldInjectPostCompactReminder,
107
108
  validateGoalAbort,
108
109
  validateGoalCompletion,
109
- validateGoalUpdate,
110
110
  validatePauseGoal,
111
111
  validateResumeGoal,
112
112
  } from "./goal-policy.ts";
@@ -128,14 +128,14 @@ const GOAL_PROGRESS_TOOL_SET = new Set<string>(GOAL_PROGRESS_TOOL_NAMES);
128
128
 
129
129
  /**
130
130
  * Tools that are NEVER blocked by the post-stop in-turn block. After pause_goal,
131
- * abort_goal, update_goal=complete, or apply_goal_tweak fires, the agent should
131
+ * abort_goal, or update_goal=complete fires, the agent should
132
132
  * yield the turn; we block all subsequent tool calls except these read-only inspections.
133
133
  */
134
134
  const POST_STOP_ALLOWED_TOOL_SET = new Set<string>(POST_STOP_ALLOWED_TOOLS);
135
135
 
136
136
  /**
137
137
  * When non-null, /goal-tweak drafting is in progress for this goal id and the
138
- * agent is allowed to call apply_goal_tweak. Cleared after the tweak is applied
138
+ * agent is allowed to call propose_goal_tweak. Cleared after the tweak is applied
139
139
  * or when a user-driven turn arrives without a tweak follow-through. This is
140
140
  * the schema-level affordance gate that prevents the agent from "tweaking" via
141
141
  * arbitrary write/edit calls.
@@ -376,7 +376,7 @@ export default function goalExtension(pi: ExtensionAPI): void {
376
376
  // Per-turn flags reset in turn_start (#4, C9 fix).
377
377
  // goalWorkToolCalledThisTurn: tracks whether a real goal-work tool was called.
378
378
  // If false at turn_end, we don't queue another autoContinue (empty chat turn).
379
- // turnStoppedFor: set by pause_goal / update_goal(complete) / apply_goal_tweak
379
+ // turnStoppedFor: set by pause_goal / update_goal(complete) / propose_goal_tweak
380
380
  // after their successful execute. Once set, pi.on("tool_call") blocks all
381
381
  // subsequent in-turn tool calls except POST_STOP_ALLOWED_TOOLS. This is the
382
382
  // schema fix for "agent keeps writing files after pause_goal".
@@ -393,18 +393,6 @@ export default function goalExtension(pi: ExtensionAPI): void {
393
393
  lastAccountedAt: null as number | null,
394
394
  };
395
395
 
396
- const draftingHiddenWorkTools = [
397
- "bash",
398
- "read",
399
- "write",
400
- "edit",
401
- "grep",
402
- "find",
403
- "ls",
404
- SISYPHUS_STEP_TOOL_NAME,
405
- TWEAK_APPLY_TOOL_NAME,
406
- CREATE_GOAL_TOOL_NAME,
407
- ] as const;
408
396
  const goalExecutionWorkTools = ["read", "bash", "edit", "write"] as const;
409
397
 
410
398
  function syncGoalTools(): void {
@@ -421,15 +409,16 @@ export default function goalExtension(pi: ExtensionAPI): void {
421
409
  // mechanism. Keep step_complete registered for legacy transcripts, but do
422
410
  // not expose it as an active work tool.
423
411
  active.delete(SISYPHUS_STEP_TOOL_NAME);
424
- // apply_goal_tweak is only available during a /goal-tweak drafting flow.
412
+ // propose_goal_tweak is only available during a /goal-tweak drafting flow.
425
413
  // Note: tweak drafting can run against active OR paused goals.
426
414
  if (state.goal && tweakDraftingFor === state.goal.id) {
427
- active.add(TWEAK_APPLY_TOOL_NAME);
415
+ active.add(PROPOSE_TWEAK_TOOL_NAME);
428
416
  active.add(QUESTION_TOOL_NAME);
429
417
  active.add(QUESTIONNAIRE_TOOL_NAME);
430
418
  } else {
431
- active.delete(TWEAK_APPLY_TOOL_NAME);
419
+ active.delete(PROPOSE_TWEAK_TOOL_NAME);
432
420
  }
421
+
433
422
  // Keep the commit tool available and let its validator enforce that a
434
423
  // drafting flow is active. This avoids fragile hidden-tool drift after
435
424
  // question turns, compaction, or active-tool resync.
@@ -1054,11 +1043,11 @@ export default function goalExtension(pi: ExtensionAPI): void {
1054
1043
  if (!focused) return;
1055
1044
  const sisyphusOn = focused.sisyphus;
1056
1045
  const label = sisyphusOn ? "Sisyphus tweak drafting" : "Goal tweak drafting";
1057
- // Activate the tweak edit-gate so apply_goal_tweak is callable.
1046
+ // Activate the tweak edit-gate so propose_goal_tweak is callable.
1058
1047
  tweakDraftingFor = focused.id;
1059
1048
  syncGoalTools();
1060
1049
  ctx.ui.notify(
1061
- `${label} started${trimmed ? `: ${truncateText(trimmed, 60)}` : ""}. The agent will interview you and then call apply_goal_tweak.`,
1050
+ `${label} started${trimmed ? `: ${truncateText(trimmed, 60)}` : ""}. The agent will interview you and then propose the revision for you to Confirm.`,
1062
1051
  "info",
1063
1052
  );
1064
1053
  const draftId = `tweak-${focused.id}-${Date.now().toString(36)}`;
@@ -1696,84 +1685,182 @@ export default function goalExtension(pi: ExtensionAPI): void {
1696
1685
  }));
1697
1686
 
1698
1687
  pi.registerTool(defineTool({
1699
- name: "update_goal",
1700
- label: "Update Goal",
1701
- description: "Mark the current active or paused pi goal complete when the objective is actually achieved.",
1702
- promptSnippet: "Mark the active or paused pi goal complete when the objective is achieved.",
1688
+ name: PROPOSE_TWEAK_TOOL_NAME,
1689
+ label: "Propose Goal Tweak",
1690
+ description: "During a /goal-tweak drafting flow, present the revised goal to the user for confirmation. The user sees a Confirm / Continue Chatting dialog — only on Confirm is the goal updated.",
1691
+ promptSnippet: "Propose the revised goal to the user with a Confirm / Continue Chatting dialog.",
1703
1692
  promptGuidelines: [
1704
- "Use update_goal with status=complete only when the pi goal objective has actually been achieved and no required work remains.",
1705
- "Before calling update_goal, summarize the evidence you believe proves completion; the tool will launch an independent pi auditor agent to inspect the workspace and judge the claim.",
1706
- "The auditor is authoritative: completion is archived only if the auditor report ends with <approved/>. If it ends with <disapproved/> or no approval marker, update_goal is rejected and the goal remains open.",
1707
- "Do not call update_goal merely because work is stopping, substantial progress was made, or tests passed without covering every requirement.",
1708
- "Do not use update_goal=complete as an escape hatch when you are blocked. If you are blocked, call pause_goal({reason, suggestedAction?}) instead so the user can intervene.",
1709
- "For sisyphus goals, do not mark complete until every numbered step has been executed and individually verified against its done criterion.",
1710
- "If the user gives requirements, feedback, or corrections that differ from the goal objective, the goal is stale. Use update_goal with updatedObjective to sync the objective before continuing work or before marking the goal complete. This ensures the auditor evaluates against the latest requirements.",
1711
- "If you have just run the test suite successfully and the tests all pass, include a testResults object with the exit code (0) and relevant output. The auditor will see this evidence and can skip re-running the tests.",
1693
+ "Only call propose_goal_tweak inside a /goal-tweak drafting flow (the prompt makes that explicit). It is rejected at any other time.",
1694
+ "newObjective must be the FULL revised objective text, formatted the same way as the original (=== Goal === or === Sisyphus Goal === block). Do NOT pass a diff or partial patch; pass the whole new objective.",
1695
+ "For Sisyphus goals: preserve the Sisyphus style and ordered-plan wording unless the user explicitly asks to remove it.",
1696
+ "changeSummary is a one-sentence description of WHAT changed (for the confirmation dialog, activity log, and tweak log).",
1697
+ "The user will see a full plain-text report plus a [Confirm] / [Continue Chatting] choice. Confirm applies the tweak; Continue Chatting returns control to you to ask follow-up questions.",
1698
+ "If the tool returns 'continue chatting', ask the user what they want changed. Do NOT re-propose the same content immediately; iterate based on their feedback first.",
1699
+ "Do NOT use write/edit/bash to modify the active goal file directly. propose_goal_tweak is the only sanctioned channel.",
1712
1700
  ],
1713
1701
  parameters: Type.Object({
1714
- status: Type.Optional(StringEnum([COMPLETE_STATUS] as const, { description: "Set to complete only when the objective is achieved." })),
1715
- completionSummary: Type.Optional(Type.String({ description: "Concise completion claim and evidence summary passed to the independent auditor agent." })),
1716
- confirmBypassAuditor: Type.Optional(Type.Boolean({ description: "Set to true to confirm bypassing the independent auditor when it is disabled in settings." })),
1717
- updatedObjective: Type.Optional(Type.String({ description: "Revised goal objective. Use when the user's requirements have changed mid-flight. The goal remains active so the agent can continue working toward the new objective. Can be combined with status=complete to update the objective before the completion audit." })),
1718
- testResults: Type.Optional(Type.Object({
1719
- exitCode: Type.Number({ description: "Exit code of the test run (0 = success)" }),
1720
- suiteName: Type.Optional(Type.String({ description: "Test suite name, e.g. 'npm test'" })),
1721
- output: Type.Optional(Type.String({ description: "Last lines of test output showing results" })),
1722
- timestamp: Type.Optional(Type.String({ description: "ISO timestamp of when tests were run" })),
1723
- }, { description: "Structured test evidence passed to the auditor so it can skip redundant test re-runs. If you have just run the test suite successfully, include this so the auditor accepts the results without re-running." })),
1702
+ newObjective: Type.String({ description: "The complete revised objective text. For Sisyphus goals, preserve the Sisyphus style unless the user explicitly changes it." }),
1703
+ changeSummary: Type.String({ description: "One-sentence description of what was changed (used in confirmation dialog and tweak log)." }),
1724
1704
  }),
1725
1705
  executionMode: "sequential",
1726
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
1706
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1727
1707
  reconcileFocusedGoalFromDisk(ctx);
1708
+ if (!state.goal) {
1709
+ return {
1710
+ content: [{ type: "text", text: "No goal is set; propose_goal_tweak is a no-op." }],
1711
+ details: goalDetails(state.goal),
1712
+ };
1713
+ }
1714
+ if (tweakDraftingFor !== state.goal.id) {
1715
+ return {
1716
+ content: [{
1717
+ type: "text",
1718
+ text: "propose_goal_tweak REJECTED: no /goal-tweak drafting flow is active for this goal. " +
1719
+ "This tool can only be called during a /goal-tweak drafting interview that the user initiated. " +
1720
+ "If you want to change the goal, ask the user to run /goal-tweak.",
1721
+ }],
1722
+ details: goalDetails(state.goal),
1723
+ };
1724
+ }
1725
+ if (state.goal.status !== "active" && state.goal.status !== "paused") {
1726
+ return {
1727
+ content: [{ type: "text", text: `Goal is ${statusLabel(state.goal)}; cannot apply a tweak.` }],
1728
+ details: goalDetails(state.goal),
1729
+ };
1730
+ }
1731
+ const newObjective = params.newObjective.trim();
1732
+ if (!newObjective) throw new Error("propose_goal_tweak requires a non-empty newObjective.");
1733
+ const changeSummary = params.changeSummary.trim();
1734
+ if (!changeSummary) throw new Error("propose_goal_tweak requires a non-empty changeSummary.");
1735
+
1736
+ // Build the confirmation dialog text.
1737
+ const draftSummary = buildTweakConfirmationText({
1738
+ currentObjective: state.goal.objective,
1739
+ newObjective,
1740
+ changeSummary,
1741
+ sisyphus: !!state.goal.sisyphus,
1742
+ });
1743
+
1744
+ const headless = shouldAutoConfirmProposal({ hasUI: ctx.hasUI, autoConfirmEnv: process.env.PI_GOAL_AUTO_CONFIRM });
1728
1745
 
1729
- // -- Phase 1: Objective update (quick sync) --
1730
- // Apply updatedObjective before any completion logic so the completion
1731
- // flow (if status=complete is also set) reads the latest objective.
1732
- if (params.updatedObjective !== undefined) {
1733
- const newObjective = params.updatedObjective.trim();
1734
- if (!newObjective) throw new Error("update_goal requires a non-empty updatedObjective.");
1735
- const updateGate = validateGoalUpdate({ goal: state.goal });
1736
- if (!updateGate.ok) {
1746
+ let decision: "confirm" | "continue";
1747
+ if (headless) {
1748
+ decision = "confirm";
1749
+ } else {
1750
+ try {
1751
+ decision = await showProposalDialog(ctx, draftSummary, state.goal.sisyphus ? "sisyphus" : "goal");
1752
+ } catch (err) {
1753
+ const message = proposalDialogFailureMessage(err);
1754
+ ctx.ui.notify(message, "error");
1737
1755
  return {
1738
- content: [{ type: "text", text: updateGate.message }],
1756
+ content: [{ type: "text", text: message }],
1739
1757
  details: goalDetails(state.goal),
1740
1758
  };
1741
1759
  }
1742
- if (!state.goal) throw new Error("Goal disappeared during objective update.");
1760
+ }
1761
+
1762
+ if (decision === "confirm") {
1763
+ // Apply the tweak: write the new objective to disk authoritatively.
1743
1764
  const next: GoalRecord = {
1744
1765
  ...state.goal,
1745
1766
  objective: newObjective,
1746
1767
  updatedAt: nowIso(),
1768
+ // Clear any prior agent pause reason — the user has redefined the work.
1769
+ pauseReason: undefined,
1770
+ pauseSuggestedAction: undefined,
1747
1771
  };
1772
+ // IMPORTANT: bypass setGoal() / persist() here. persist() calls
1773
+ // syncGoalPromptFromDisk() which would RE-READ the stale objective
1774
+ // from the still-old goal file on disk and clobber our new objective
1775
+ // before writing. propose_goal_tweak is the authoritative source for
1776
+ // objective changes — the disk is downstream, not upstream. Do the
1777
+ // minimal state update manually:
1778
+ // 1) write the new record to disk authoritatively
1779
+ // 2) update in-memory `goal` to the canonical post-write record
1780
+ // 3) append the state entry and re-sync tools
1781
+ // 4) clear the tweak drafting gate so propose_goal_tweak can't be re-used
1748
1782
  state.goal = writeActiveGoalFile(ctx, next);
1749
1783
  pi.appendEntry(STATE_ENTRY, goalDetails(state.goal));
1784
+ tweakDraftingFor = null;
1785
+ // Reset autoContinue counter — plan changed, agent gets a fresh chain.
1786
+ resetGetGoalNudgeState(state.goal.id);
1787
+ // Mark turn-stopped so subsequent in-turn tool calls are blocked.
1788
+ turnStoppedFor = state.goal.id;
1789
+ syncGoalTools();
1790
+ updateUI(ctx);
1791
+ ctx.ui.notify(`Goal tweaked: ${truncateText(changeSummary, 160)}`, "info");
1792
+ // Append ledger event for tweak
1750
1793
  try {
1751
1794
  appendGoalEvent(ctx, {
1752
1795
  type: "goal_tweaked",
1753
1796
  goalId: state.goal.id,
1754
- changeSummary: "Objective updated via update_goal",
1797
+ changeSummary,
1755
1798
  at: state.goal.updatedAt,
1756
1799
  });
1757
1800
  } catch {
1758
- // Ledger append failure should not block update
1759
- }
1760
- updateUI(ctx);
1761
-
1762
- // Quick sync only (no status=complete) — return without terminating
1763
- if (params.status !== COMPLETE_STATUS) {
1764
- return {
1765
- content: [{ type: "text", text: `Goal objective updated.` }],
1766
- details: goalDetails(state.goal),
1767
- };
1801
+ // Ledger append failure should not crash tweak
1768
1802
  }
1769
- // Fall through: status=complete also set, proceed with completion below
1803
+ return {
1804
+ content: [{
1805
+ type: "text",
1806
+ text: `Goal tweak applied. ${changeSummary}\nStop now; the next continuation will arrive automatically if the goal is active.`,
1807
+ }],
1808
+ details: goalDetails(state.goal),
1809
+ terminate: true,
1810
+ };
1770
1811
  }
1771
1812
 
1813
+ // "continue" — user wants to keep chatting. Drafting state stays armed.
1814
+ return {
1815
+ content: [{
1816
+ type: "text",
1817
+ text: "Goal tweak refinement requested (Continue Chatting). The tweak was not applied — drafting remains active. Ask the user what they want changed about the revision, then revise and call propose_goal_tweak again. Do not re-propose the same content — wait for the user's input first.",
1818
+ }],
1819
+ details: goalDetails(state.goal),
1820
+ };
1821
+ },
1822
+ renderCall(args, theme) {
1823
+ const summary = typeof args?.changeSummary === "string" ? truncateText(args.changeSummary, 80) : "";
1824
+ return new Text(theme.fg("toolTitle", "propose_goal_tweak ") + theme.fg("muted", summary), 0, 0);
1825
+ },
1826
+ renderResult(result, _options, theme) {
1827
+ return renderGoalResult(result, theme);
1828
+ },
1829
+ }));
1830
+
1831
+ pi.registerTool(defineTool({
1832
+ name: "update_goal",
1833
+ label: "Update Goal",
1834
+ description: "Mark the current active or paused pi goal complete when the objective is actually achieved.",
1835
+ promptSnippet: "Mark the active or paused pi goal complete when the objective is achieved.",
1836
+ promptGuidelines: [
1837
+ "Use update_goal with status=complete only when the pi goal objective has actually been achieved and no required work remains.",
1838
+ "Before calling update_goal, summarize the evidence you believe proves completion; the tool will launch an independent pi auditor agent to inspect the workspace and judge the claim.",
1839
+ "The auditor is authoritative: completion is archived only if the auditor report ends with <approved/>. If it ends with <disapproved/> or no approval marker, update_goal is rejected and the goal remains open.",
1840
+ "Do not call update_goal merely because work is stopping, substantial progress was made, or tests passed without covering every requirement.",
1841
+ "Do not use update_goal=complete as an escape hatch when you are blocked. If you are blocked, call pause_goal({reason, suggestedAction?}) instead so the user can intervene.",
1842
+ "For sisyphus goals, do not mark complete until every numbered step has been executed and individually verified against its done criterion.",
1843
+ "The goal objective is immutable. The agent MUST NOT modify the goal objective on its own initiative. If the user gives requirements, feedback, or corrections that differ from the goal objective, ask the user to run /goal-tweak to revise the goal. Use goal_question to confirm when the change is ambiguous.",
1844
+ "If you have just run the test suite successfully and the tests all pass, include a testResults object with the exit code (0) and relevant output. The auditor will see this evidence and can skip re-running the tests.",
1845
+ ],
1846
+ parameters: Type.Object({
1847
+ status: Type.Optional(StringEnum([COMPLETE_STATUS] as const, { description: "Set to complete only when the objective is achieved." })),
1848
+ completionSummary: Type.Optional(Type.String({ description: "Concise completion claim and evidence summary passed to the independent auditor agent." })),
1849
+ confirmBypassAuditor: Type.Optional(Type.Boolean({ description: "Set to true to confirm bypassing the independent auditor when it is disabled in settings." })),
1850
+
1851
+ testResults: Type.Optional(Type.Object({
1852
+ exitCode: Type.Number({ description: "Exit code of the test run (0 = success)" }),
1853
+ suiteName: Type.Optional(Type.String({ description: "Test suite name, e.g. 'npm test'" })),
1854
+ output: Type.Optional(Type.String({ description: "Last lines of test output showing results" })),
1855
+ timestamp: Type.Optional(Type.String({ description: "ISO timestamp of when tests were run" })),
1856
+ }, { description: "Structured test evidence passed to the auditor so it can skip redundant test re-runs. If you have just run the test suite successfully, include this so the auditor accepts the results without re-running." })),
1857
+ }, { additionalProperties: false }),
1858
+ executionMode: "sequential",
1859
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
1860
+ reconcileFocusedGoalFromDisk(ctx);
1861
+
1772
1862
  // -- Phase 2: Status validation --
1773
1863
  if (params.status !== COMPLETE_STATUS) {
1774
- if (params.updatedObjective === undefined) {
1775
- throw new Error("update_goal requires either status=complete or updatedObjective.");
1776
- }
1777
1864
  throw new Error("update_goal requires status=complete when marking a goal complete.");
1778
1865
  }
1779
1866
 
@@ -2074,7 +2161,7 @@ export default function goalExtension(pi: ExtensionAPI): void {
2074
2161
  };
2075
2162
  },
2076
2163
  renderCall(args, theme) {
2077
- const label = args?.status ?? args?.updatedObjective ? "sync" : "";
2164
+ const label = args?.status ?? "";
2078
2165
  return new Text(theme.fg("toolTitle", "update_goal ") + theme.fg("success", label), 0, 0);
2079
2166
  },
2080
2167
  renderResult(result, _options, theme) {
@@ -2247,110 +2334,6 @@ export default function goalExtension(pi: ExtensionAPI): void {
2247
2334
  },
2248
2335
  }));
2249
2336
 
2250
- pi.registerTool(defineTool({
2251
- name: TWEAK_APPLY_TOOL_NAME,
2252
- label: "Apply Goal Tweak",
2253
- description: "Atomically apply a /goal-tweak revision to the active goal. The ONLY way to modify an active goal's objective. Only available during a /goal-tweak drafting flow.",
2254
- promptSnippet: "Apply the revised goal objective produced by a /goal-tweak drafting interview.",
2255
- promptGuidelines: [
2256
- "Only call apply_goal_tweak inside a /goal-tweak drafting flow (the prompt makes that explicit). It is rejected at any other time.",
2257
- "newObjective must be the FULL revised objective text, formatted the same way as the original (=== Goal === or === Sisyphus Goal === block). Do NOT pass a diff or partial patch; pass the whole new objective.",
2258
- "For Sisyphus goals: preserve the Sisyphus style and ordered-plan wording unless the user explicitly asks to remove it.",
2259
- "changeSummary is a one-sentence description of WHAT changed (for the activity log and pause messages).",
2260
- "Do NOT use write/edit/bash to modify the active goal file directly. apply_goal_tweak is the only sanctioned channel.",
2261
- "After apply_goal_tweak returns, stop. Do not begin new task work in the same turn. The system will queue the next continuation.",
2262
- ],
2263
- parameters: Type.Object({
2264
- newObjective: Type.String({ description: "The complete revised objective text. For Sisyphus goals, preserve the Sisyphus style unless the user explicitly changes it." }),
2265
- changeSummary: Type.String({ description: "One-sentence description of what was changed (used in UI notification and tweak log)." }),
2266
- }),
2267
- executionMode: "sequential",
2268
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
2269
- reconcileFocusedGoalFromDisk(ctx);
2270
- if (!state.goal) {
2271
- return {
2272
- content: [{ type: "text", text: "No goal is set; apply_goal_tweak is a no-op." }],
2273
- details: goalDetails(state.goal),
2274
- };
2275
- }
2276
- if (tweakDraftingFor !== state.goal.id) {
2277
- return {
2278
- content: [{
2279
- type: "text",
2280
- text: "apply_goal_tweak REJECTED: no /goal-tweak drafting flow is active for this goal. " +
2281
- "This tool can only be called during a /goal-tweak drafting interview that the user initiated. " +
2282
- "If you want to change the goal, ask the user to run /goal-tweak.",
2283
- }],
2284
- details: goalDetails(state.goal),
2285
- };
2286
- }
2287
- if (state.goal.status !== "active" && state.goal.status !== "paused") {
2288
- return {
2289
- content: [{ type: "text", text: `Goal is ${statusLabel(state.goal)}; cannot apply a tweak.` }],
2290
- details: goalDetails(state.goal),
2291
- };
2292
- }
2293
- const newObjective = params.newObjective.trim();
2294
- if (!newObjective) throw new Error("apply_goal_tweak requires a non-empty newObjective.");
2295
- const changeSummary = params.changeSummary.trim();
2296
- if (!changeSummary) throw new Error("apply_goal_tweak requires a non-empty changeSummary.");
2297
- const next: GoalRecord = {
2298
- ...state.goal,
2299
- objective: newObjective,
2300
- updatedAt: nowIso(),
2301
- // Clear any prior agent pause reason — the user has redefined the work.
2302
- pauseReason: undefined,
2303
- pauseSuggestedAction: undefined,
2304
- };
2305
- // IMPORTANT: bypass setGoal() / persist() here. persist() calls
2306
- // syncGoalPromptFromDisk() which would RE-READ the stale objective
2307
- // from the still-old goal file on disk and clobber our new objective
2308
- // before writing. apply_goal_tweak is the authoritative source for
2309
- // objective changes — the disk is downstream, not upstream. Do the
2310
- // minimal state update manually:
2311
- // 1) write the new record to disk authoritatively
2312
- // 2) update in-memory `goal` to the canonical post-write record
2313
- // 3) append the state entry and re-sync tools
2314
- // 4) clear the tweak drafting gate so apply_goal_tweak can't be re-used
2315
- state.goal = writeActiveGoalFile(ctx, next);
2316
- pi.appendEntry(STATE_ENTRY, goalDetails(state.goal));
2317
- tweakDraftingFor = null;
2318
- // Reset autoContinue counter — plan changed, agent gets a fresh chain.
2319
- resetGetGoalNudgeState(state.goal.id);
2320
- // C9 fix: mark turn-stopped so subsequent in-turn tool calls are blocked.
2321
- turnStoppedFor = state.goal.id;
2322
- syncGoalTools();
2323
- updateUI(ctx);
2324
- ctx.ui.notify(`Goal tweaked: ${truncateText(changeSummary, 160)}`, "info");
2325
- // Append ledger event for tweak
2326
- try {
2327
- appendGoalEvent(ctx, {
2328
- type: "goal_tweaked",
2329
- goalId: state.goal.id,
2330
- changeSummary,
2331
- at: state.goal.updatedAt,
2332
- });
2333
- } catch {
2334
- // Ledger append failure should not crash tweak
2335
- }
2336
- return {
2337
- content: [{
2338
- type: "text",
2339
- text: `Goal tweak applied. ${changeSummary}\nStop now; the next continuation will arrive automatically if the goal is active.`,
2340
- }],
2341
- details: goalDetails(state.goal),
2342
- terminate: true,
2343
- };
2344
- },
2345
- renderCall(args, theme) {
2346
- const summary = typeof args?.changeSummary === "string" ? truncateText(args.changeSummary, 80) : "";
2347
- return new Text(theme.fg("toolTitle", "apply_goal_tweak ") + theme.fg("muted", summary), 0, 0);
2348
- },
2349
- renderResult(result, _options, theme) {
2350
- return renderGoalResult(result, theme);
2351
- },
2352
- }));
2353
-
2354
2337
  syncGoalTools();
2355
2338
 
2356
2339
  pi.on("context", async (event): Promise<{ messages: typeof event.messages } | undefined> => {
@@ -2400,7 +2383,7 @@ export default function goalExtension(pi: ExtensionAPI): void {
2400
2383
  // #4 + C9 fix + Phase 5 C3: gate in-turn tool calls based on lifecycle state.
2401
2384
  pi.on("tool_call", async (event, ctx) => {
2402
2385
  // Post-stop in-turn block (C9 0ad8 fix): after pause_goal / abort_goal /
2403
- // update_goal=complete / apply_goal_tweak fires in this turn, block all subsequent tool calls except
2386
+ // update_goal=complete / propose_goal_tweak fires in this turn, block all subsequent tool calls except
2404
2387
  // read-only inspection. Forces the agent to yield the turn instead of "fixing"
2405
2388
  // the situation by creating extra files etc.
2406
2389
  if (turnStoppedFor !== null && !POST_STOP_ALLOWED_TOOL_SET.has(event.toolName)) {
@@ -48,7 +48,7 @@ If the user explicitly asks to abandon/cancel this goal, or the objective is obs
48
48
 
49
49
  Do NOT silently invent workarounds, fake completion, or quietly redefine the objective. Do NOT call update_goal=complete to escape a blocker.
50
50
 
51
- Goal evolution: if the user gives requirements, feedback, or corrections that differ from the goal objective, the goal is stale. Propose the updated objective concisely and wait for the user to confirm before continuing. Use update_goal with updatedObjective for narrow focus-area changes, or suggest /goal-tweak for broader revisions (boundaries, constraints, multiple sections). Do NOT mark the goal complete with a stale objective.${sisyphusDisciplineBlock(goal) ? `\n${sisyphusDisciplineBlock(goal)}` : ""}`;
51
+ Goal evolution: if the user gives requirements, feedback, or corrections that differ from the goal objective, the goal is stale. The goal objective is immutable the agent must NOT modify it autonomously. Propose the updated objective concisely and ask the user to run /goal-tweak to revise it. Do NOT mark the goal complete with a stale objective.${sisyphusDisciplineBlock(goal) ? `\n${sisyphusDisciplineBlock(goal)}` : ""}`;
52
52
  }
53
53
 
54
54
  export function continuationPrompt(goal: GoalRecord): string {
@@ -83,7 +83,7 @@ export function continuationPrompt(goal: GoalRecord): string {
83
83
  "Do not call update_goal unless the goal is complete enough to survive independent semantic auditing. Do not mark a goal complete merely because work is stopping.",
84
84
  "Do not ask the user for confirmation unless there is a real blocker.",
85
85
  "",
86
- "Goal evolution: if the user gives requirements, feedback, or corrections that differ from the goal objective, the goal is stale. Propose the updated objective concisely and wait for the user to confirm before continuing. Use update_goal with updatedObjective for narrow focus-area changes, or suggest /goal-tweak for broader revisions (boundaries, constraints, multiple sections). Do NOT mark the goal complete with a stale objective.",
86
+ "Goal evolution: if the user gives requirements, feedback, or corrections that differ from the goal objective, the goal is stale. The goal objective is immutable the agent must NOT modify it autonomously. Propose the updated objective concisely and ask the user to run /goal-tweak to revise it. Do NOT mark the goal complete with a stale objective.",
87
87
  "",
88
88
  "If you hit a real blocker (missing credentials, contradictory spec, file/permission you cannot access, dangerous operation pending user approval, or an unclear Sisyphus-style ordered plan), call pause_goal({reason, suggestedAction?}) and stop. If the user explicitly asks to abandon/cancel, or the objective is obsolete, impossible, or unsafe to continue, call abort_goal({reason}) and stop. Do not silently invent workarounds. Do not fake completion. pause_goal and abort_goal are structured lifecycle exits; update_goal=complete is not an escape hatch for blockers.",
89
89
  ...(goal.sisyphus ? ["", sisyphusDisciplineBlock(goal)] : []),
@@ -139,17 +139,19 @@ export function goalTweakDraftingPrompt(current: GoalRecord, hint: string): stri
139
139
  ...focusItems,
140
140
  "",
141
141
  "When the revision is clear:",
142
- "1. Call apply_goal_tweak with:",
142
+ "1. Call propose_goal_tweak with:",
143
143
  " - newObjective: the FULL revised objective text, formatted the same way as the original" + (sisyphusOn
144
144
  ? " === Sisyphus Goal === block (Objective / Success criteria / Boundaries / Constraints / If blocked / Sisyphus reminder)."
145
145
  : " === Goal === block (Objective / Success criteria / Boundaries / Constraints / If blocked)."),
146
146
  " - changeSummary: one sentence describing what changed.",
147
- "2. apply_goal_tweak is the ONLY sanctioned way to change an active goal's objective. It atomically updates the goal record and the on-disk file. Do not attempt to bypass it.",
148
- "3. After apply_goal_tweak returns, stop. If the goal is active, the next continuation will arrive automatically. If the goal is paused, the user will resume it explicitly. Either way, do not begin task work in this same turn.",
147
+ "2. propose_goal_tweak opens the user's Confirm / Continue Chatting dialog.",
148
+ " - Confirm applies the tweak. Stop; the next continuation will arrive automatically if the goal is active.",
149
+ " - Continue Chatting means the drafting stays active — ask the user what they want changed, then revise and call propose_goal_tweak again.",
150
+ "3. propose_goal_tweak is the ONLY sanctioned way to change an active goal's objective. It atomically updates the goal record and the on-disk file. Do not attempt to bypass it.",
149
151
  "",
150
152
  "Edge cases:",
151
- "- If you decide no change is actually needed, say so clearly in one sentence and stop without calling apply_goal_tweak.",
152
- "- If the hint conflicts with the existing goal in a major way, propose two or three concrete alternative revisions and let the user pick before calling apply_goal_tweak.",
153
+ "- If you decide no change is actually needed, say so clearly in one sentence and stop without calling propose_goal_tweak.",
154
+ "- If the hint conflicts with the existing goal in a major way, propose two or three concrete alternative revisions and let the user pick before calling propose_goal_tweak.",
153
155
  ].join("\n");
154
156
  }
155
157
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-x",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Goal mode extension for pi: persistent long-running objectives, /goal-set drafting, Sisyphus prompt style, autoContinue, and an above-editor status overlay. Fork of @capyup/pi-goal.",
5
5
  "license": "MIT",
6
6
  "author": "pi-goal-x contributors",