pi-goal-x 0.25.1 → 0.25.3

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
@@ -249,22 +249,24 @@ Verify that the generated report contains every required section.
249
249
  Always visible above the editor while a goal is focused:
250
250
 
251
251
  ```text
252
- ╭─ pi-goal-x ─ Add CSV export to reports ─────── 12m47s · 18.2K tok ─╮
253
- │ ● In progress · Focused: yes · Other goals: 2 │
254
- │ Tasks [██████░░░░] 3/5 · 60% │
255
- ├─ Tasks ────────────────────────────────────────────────────────────┤
252
+ ╭─ pi-goal-x ─ Add CSV export to reports ────────────────────────────╮
253
+ │ goal: running [12m47s 18.2K] (+2 open) │
254
+ ├─ Tasks · ✓3 done · 2 open ──────────── [█████░░░] · Sub 2/3 [██░░] ┤
256
255
  │ ✓ t1 Review reports page and data source │
257
256
  │ ✓ t2 Implement filtered CSV export │
258
- │ ▸ t3 Add the download button │
257
+ │ ▸ t3 Add the download button ☑ ▸ 2/3 │
259
258
  │ · t4 Add documentation │
260
259
  │ … +1 more task │
261
260
  │ Current t3 · Add the download button │
262
- │ Subtasks [███████░░░] 2/3 · 67% │
263
261
  │ Verify Run npm test with zero failures. │
264
262
  │ File .pi/goals/active_goal_... │
265
- ╰─ Ctrl+Shift+T: expand tasks ───────────────────────────────────────╯
263
+ ╰─ Ctrl+Shift+T: expand tasks · Ctrl+Shift+A ──────────────────── ● ─╯
266
264
  ```
267
265
 
266
+ The green/gray `●` at the bottom-right of the border is the focused goal's
267
+ independent-auditor status (green = on, gray = off); `Ctrl+Shift+A` toggles
268
+ it per-goal (wide/medium footers advertise the chord).
269
+
268
270
  ### Expanded mode
269
271
 
270
272
  `Ctrl+Shift+T` expands the same component: full task tree (✓ complete, ▸ current, ~ skipped, · pending), the current-task block with its contract and evidence, goal-level verification, and recent activity.
@@ -353,7 +355,7 @@ Settings are stored in:
353
355
  .pi/pi-goal-x-settings.json
354
356
  ```
355
357
 
356
- Use `/goal-settings` to configure task lists, verification contracts, subtask depth, automatic goal selection, and completion auditing.
358
+ Use `/goal-settings` to configure task lists, verification contracts, subtask depth, automatic goal selection, and completion auditing. Goal objectives have no hard length limit by default; set `objectiveMaxChars` (or `PI_GOAL_OBJECTIVE_MAX_CHARS`, `0` = no limit) to cap objective length across `create_goal`, `propose_goal_draft`, and `/goal-tweak`.
357
359
 
358
360
  ## License
359
361
 
@@ -21,53 +21,66 @@ activity are never fabricated for display.
21
21
  Always visible above the editor while a goal is focused:
22
22
 
23
23
  ```text
24
- ╭─ pi-goal-x ─ Add CSV export to reports────────────── 12m47s · 18.2K tok ─╮
25
- │ ● In progress · Focused: yes · Other goals: 2 │
26
- │ Tasks [███████████░░░░░░░] 3/5 · 60% │
27
- ├─ Tasks ─────────────────────────────────────────────────────────────────┤
28
- │ ✓ t1 Review reports page and data source │
29
- │ ✓ t2 Implement filtered CSV export │
30
- │ ▸ t3 Add the download button │
31
- │ · t4 Add documentation │
32
- │ … +1 more task │
33
- │ Current t3 · Add the download button │
34
- │ Subtasks [████████████░░░░░░] 2/3 · 67% │
35
- │ Verify Run npm test with zero failures. │
36
- │ File .pi/goals/active_goal_g1.md │
37
- ╰─ Ctrl+Shift+T: expand tasks─────────────────────────────────────────────╯
24
+ ╭─ pi-goal-x ─ Add CSV export to reports ────────────────────────────────────────────────╮
25
+ │ goal: running [12m47s 18.2K] (+2 open) │
26
+ ├─ Tasks · ✓3 done · 2 open ─────────────────────────────────── [████░░] · Sub 2/3 [██░] ┤
27
+ │ ✓ t1 Review reports page and data source │
28
+ │ ✓ t2 Implement filtered CSV export │
29
+ │ ▸ t3 Add the download button ☑ ▸ 2/3 │
30
+ │ · t4 Add documentation │
31
+ │ … +1 more task │
32
+ │ Current t3 · Add the download button │
33
+ │ Verify Run npm test with zero failures. │
34
+ │ File .pi/goals/active_goal_g1.md │
35
+ ╰─ Ctrl+Shift+T: expand tasks · Ctrl+Shift+A ──────────────────────────────────────── ● ─╯
38
36
  ```
39
37
 
40
38
  Compact rows, in order:
41
39
 
42
- 1. Header (rounded corners): title plus elapsed time and token usage.
43
- 2. Status: colored symbol + explicit label, focus state, other open goals
44
- (`·`-separated). Colors come from the theme with a monochrome fallback.
40
+ 1. Header (rounded corners): title. (Usage lives in the status line below,
41
+ not in the header.)
42
+ 2. Status: `goal: <status> [<elapsed> <tokens>] (+N open)` — the same
43
+ terminology as the pi footer status line.
45
44
  3. Token budget (when configured): `⛽ Budget 18.2K / 50K · 36%`.
46
- 4. Top-level task progress (`3/5 · 60%`); a skipped top-level task counts as
47
- done (§9.1). The whole progress element is neutral gray — bar fill and
48
- empty cells included — so nothing in the progress info is colourful.
49
- 5. Task list section: the top-level tasks shown by default in **pastel
45
+ 4. Tasks header row: `Tasks · ✓N done · M open` with the compact task
46
+ progress bar, then the current task's subtask bar beside it
47
+ (` · Sub done/total ` + bar — the word `Sub` is dropped in narrow mode so
48
+ the full counts still fit at 50 columns, and the segment is omitted
49
+ entirely in minimal mode, where the counts and task bar alone use all
50
+ available space). `N` counts top-level tasks complete **or skipped** — a
51
+ skipped top-level task counts as done (§9.1) — and `M` counts pending
52
+ tasks. The subtask segment appears only when the current task has direct
53
+ children. The whole header row is neutral gray (label, counts, bar fill,
54
+ and empty cells) so nothing in the progress info is colourful.
55
+ 6. Task list section: the top-level tasks shown by default in **pastel
50
56
  amber** (`mdHeading`) with colour-coded markers and ids (✓ complete muted
51
57
  green, ▸ current teal, ~ skipped gray, · pending amber), an aligned id
52
58
  column, truncated amber titles, and a `… +N more` overflow line when the
53
59
  list is longer than the row budget (5 rows at wide, 4 medium, 3 narrow, 2
54
- minimal). The current task is fully highlighted in the theme's accent
55
- color (marker, id, title). The whole box chrome — top/bottom frame,
56
- left/right edges, corners and interior rules — is drawn in one tone: the
57
- theme's neutral gray (`muted`). No blue tinge.
60
+ minimal). Tasks that have direct subtasks show a muted `▸ done/total`
61
+ marker at the end of their row (leaf tasks show none). The current task
62
+ is fully highlighted in the theme's accent color (marker, id, title). The
63
+ whole box chrome — top/bottom frame, left/right edges, corners and
64
+ interior rules — is drawn in one tone: the theme's neutral gray
65
+ (`muted`). No blue tinge.
58
66
  6. Current task: `Current t3 · Add the download button` (id and title in the
59
67
  accent color).
60
- 7. Current-task subtask progress (`2/3 · 67%`) when the current task has
61
- direct children (the current task's subtasks stay inline). The subtask
62
- bar and its fraction are neutral gray like the top-level progress.
63
- 8. Goal-level verification contract (first line, truncated).
64
- 9. Blocked or paused detail (reason, suggested action).
65
- 10. Active (or archived) goal file path.
66
- 11. Footer with the expansion shortcut. The whole footer line — leading dash,
68
+ 7. Goal-level verification contract (first line, truncated).
69
+ 8. Blocked or paused detail (reason, suggested action).
70
+ 9. Active (or archived) goal file path.
71
+ 10. Footer with the expansion shortcut. The whole footer line — leading dash,
67
72
  shortcut hint and trailing fill — is drawn in one frame tone (the
68
73
  neutral-gray `muted`), so the color spans the full width with no two-tone
69
74
  split; the header line is likewise one tone with only the `pi-goal-x`
70
- brand in accent.
75
+ brand in accent. The auditor status lives in the bottom-right of this
76
+ border: a single `●` — green when the focused goal's independent auditor
77
+ is on, gray when off — drawn right before the `╯` corner, and the
78
+ wide/medium footers also append ` · Ctrl+Shift+A` to the hint (narrow and
79
+ minimal keep the dot alone). `Ctrl+Shift+A` toggles the auditor per-goal
80
+ (persisted, `auditor_toggled` ledger event, dashboard refresh,
81
+ notification; inert with no focused goal, a goal modal open, or a
82
+ complete goal). The expanded dashboard does not render the auditor
83
+ indicator.
71
84
 
72
85
  When every top-level task is done, the current line reads
73
86
  `Current All tasks complete`. With `disableTasks` enabled the task rows are
@@ -207,6 +220,7 @@ auditor's findings, and returns to the normal dashboard so work can continue.
207
220
  | Key | Action |
208
221
  | --- | --- |
209
222
  | `Ctrl+Shift+T` | Toggle the dashboard between compact and expanded |
223
+ | `Ctrl+Shift+A` | Toggle the focused goal's independent auditor on/off (persisted per-goal; inert with no goal, a goal modal open, or a complete goal) |
210
224
  | `↑` / `↓`, `PgUp` / `PgDn`, `Home` / `End` | Scroll the expanded task tree (see [Scrolling the task list](#scrolling-the-task-list)) |
211
225
  | `Ctrl+Shift+↑` / `Ctrl+Shift+↓`, `Ctrl+Shift+PgUp` / `Ctrl+Shift+PgDn`, `Ctrl+Shift+Home` / `Ctrl+Shift+End` | Scroll the compact task list |
212
226
  | `Esc` | Collapse the expanded dashboard; otherwise pause the goal |
@@ -61,6 +61,8 @@ function mapEvent(event: GoalLedgerEvent, taskTitles: ReadonlyMap<string, string
61
61
  return { at: event.at, kind: "goal", text: "Created and focused the goal." };
62
62
  case "goal_tweaked":
63
63
  return { at: event.at, kind: "goal", text: "Updated the goal objective and task plan." };
64
+ case "auditor_toggled":
65
+ return { at: event.at, kind: "goal", text: event.enabled ? "Turned the independent auditor on." : "Turned the independent auditor off." };
64
66
  case "goal_paused":
65
67
  return {
66
68
  at: event.at,
@@ -262,6 +262,7 @@ export function registerGoalCommands(core: GoalCore): void {
262
262
  { key: "autoSelectSingleGoal", label: "autoSelectSingleGoal", section: "Goal behavior", kind: "boolean" },
263
263
  { key: "disableContracts", label: "disableContracts", section: "Goal behavior", kind: "boolean" },
264
264
  { key: "stallTimeoutMinutes", label: "stall timeout (minutes)", section: "Goal behavior", kind: "positiveInteger" },
265
+ { key: "objectiveMaxChars", label: "max objective length (0 = none)", section: "Goal behavior", kind: "positiveInteger" },
265
266
  { key: "disableTasks", label: "disableTasks", section: "Task tracking", kind: "boolean" },
266
267
  { key: "subtaskDepth", label: "subtaskDepth", section: "Task tracking", kind: "positiveInteger" },
267
268
  { key: "disabled", label: "auditor disabled", section: "Completion auditor", kind: "boolean" },
@@ -276,6 +277,7 @@ export function registerGoalCommands(core: GoalCore): void {
276
277
  }
277
278
  if (key === "subtaskDepth") return config.subtaskDepth !== undefined ? String(config.subtaskDepth) : "1";
278
279
  if (key === "stallTimeoutMinutes") return config.stallTimeoutMinutes !== undefined ? String(config.stallTimeoutMinutes) : "0";
280
+ if (key === "objectiveMaxChars") return config.objectiveMaxChars !== undefined ? String(config.objectiveMaxChars) : "0";
279
281
  return config[key] ?? "(default)";
280
282
  }
281
283
 
@@ -343,8 +345,9 @@ export function registerGoalCommands(core: GoalCore): void {
343
345
  const input = await ctx.ui.input(`Set ${row.label}`, settingsValue(config, key));
344
346
  if (input === undefined) continue;
345
347
  // Row-driven lower bound: subtaskDepth is a nesting depth (min 1);
346
- // stallTimeoutMinutes defaults to 0 meaning "no stall timeout".
347
- const min = row.key === "stallTimeoutMinutes" ? 0 : 1;
348
+ // stallTimeoutMinutes and objectiveMaxChars default to 0 meaning
349
+ // "off / no limit".
350
+ const min = (row.key === "stallTimeoutMinutes" || row.key === "objectiveMaxChars") ? 0 : 1;
348
351
  // Full-string decimal validation: no partial parseInt. Rejects
349
352
  // 1.5, 1x, negatives, infinity, and unsafe integers alike.
350
353
  const trimmed = input.trim();
@@ -478,9 +481,12 @@ export function registerGoalCommands(core: GoalCore): void {
478
481
  ctx.ui.notify("Provide the replacement objective: /goal-tweak <new objective>", "info");
479
482
  return;
480
483
  }
481
- if (trimmed.length > 4000) {
482
- ctx.ui.notify(`Replacement objective exceeds 4000 characters (${trimmed.length}).`, "warning");
483
- return;
484
+ if (trimmed.length > (loadGoalSettings(ctx.cwd).objectiveMaxChars ?? 0)) {
485
+ const max = loadGoalSettings(ctx.cwd).objectiveMaxChars;
486
+ if (max !== undefined && max > 0) {
487
+ ctx.ui.notify(`Replacement objective exceeds ${max} characters (${trimmed.length}).`, "warning");
488
+ return;
489
+ }
484
490
  }
485
491
  await startGoalDrafting(core, ctx, "tweak", trimmed, currentGoal);
486
492
  }
@@ -37,6 +37,9 @@ export function buildGoalCompactSummary(goal: GoalRecord, events: GoalLedgerEven
37
37
  case "goal_tweaked":
38
38
  lines.push(` - tweaked: ${event.changeSummary}`);
39
39
  break;
40
+ case "auditor_toggled":
41
+ lines.push(` - auditor toggled ${event.enabled ? "on" : "off"}`);
42
+ break;
40
43
  case "completion_requested":
41
44
  lines.push(` - completion requested${event.summary ? `: ${truncateText(event.summary, 80)}` : ""}`);
42
45
  break;
@@ -8,6 +8,7 @@ import { budgetLine } from "./goal-accounting.ts";
8
8
  import { buildGoalCreatedReport, buildTaskSummary, validateGoalAgentPause, validateGoalBlock } from "./goal-policy.ts";
9
9
  import { buildUnfocusedOpenGoalsSummary, otherOpenGoalCount } from "./goal-pool.ts";
10
10
  import { readGoalLedger } from "./goal-ledger.ts";
11
+ import { loadGoalSettings } from "./goal-settings.ts";
11
12
  import { buildGoalHistoryBlock, buildGoalTaskDetailBlock } from "./goal-format.ts";
12
13
  import { sisyphusStepProgress } from "./goal-policy.ts";
13
14
  import { deriveTasksFromObjective } from "./goal-task-derive.ts";
@@ -105,7 +106,7 @@ pi.registerTool(defineTool({
105
106
  "Pass mode=\"sisyphus\" only when the user explicitly invoked Sisyphus mode.",
106
107
  ],
107
108
  parameters: Type.Object({
108
- objective: Type.String({ description: "Full goal text. For Sisyphus goals this MUST include the user's numbered steps + per-step done criteria, taken faithfully from the user's input. 1-4000 characters." }),
109
+ objective: Type.String({ description: "Full goal text. For Sisyphus goals this MUST include the user's numbered steps + per-step done criteria, taken faithfully from the user's input. Length is capped by the `max objective length` goal setting (0/unset = no limit)." }),
109
110
  mode: Type.Optional(StringEnum(["regular", "sisyphus"] as const, { description: "Goal mode. Defaults to regular. Use sisyphus only when the user explicitly invoked Sisyphus mode." })),
110
111
  token_budget: Type.Optional(Type.Integer({ minimum: 1, description: "Optional token budget in whole tokens. Accept it only when the user explicitly supplied a budget; never invent one." })),
111
112
  }, { additionalProperties: false }),
@@ -114,9 +115,10 @@ pi.registerTool(defineTool({
114
115
  core.reconcileFocusedGoalFromDisk(ctx);
115
116
  const objective = params.objective.trim();
116
117
  if (!objective) throw new Error("create_goal requires a non-empty objective.");
117
- if (objective.length > 4000) {
118
+ const objectiveMaxChars = loadGoalSettings(ctx.cwd).objectiveMaxChars ?? 0;
119
+ if (objectiveMaxChars > 0 && objective.length > objectiveMaxChars) {
118
120
  return {
119
- content: [{ type: "text", text: `create_goal objective exceeds 4000 characters (${objective.length}). Shorten the objective and retry.` }],
121
+ content: [{ type: "text", text: `create_goal objective exceeds ${objectiveMaxChars} characters (${objective.length}). Shorten the objective or raise the max objective length setting.` }],
120
122
  details: goalDetails(core.state.goal),
121
123
  };
122
124
  }
@@ -153,7 +153,14 @@ export async function startGoalDrafting(core: GoalCore, ctx: ExtensionContext, m
153
153
  clearGoalDrafting(core, ctx);
154
154
  }
155
155
  const startedAt = nowIso();
156
- const auditorEnabled = !loadGoalSettings(ctx.cwd).disabled;
156
+ // §tweak-persist: a tweak confirmation defaults to the target goal's
157
+ // persisted per-goal auditor setting (skipAuditor); the global `auditor
158
+ // disabled` setting is only the fallback when the goal has no per-goal
159
+ // value. Confirming a tweak must never silently re-enable or disable the
160
+ // auditor the user chose when the goal was created.
161
+ const auditorEnabled = mode === "tweak"
162
+ ? !(targetGoal?.skipAuditor ?? loadGoalSettings(ctx.cwd).disabled)
163
+ : !loadGoalSettings(ctx.cwd).disabled;
157
164
  activeDrafts.set(core, { mode, originalTopic: trimmed, targetGoalId: targetGoal?.id, startedAt, auditorEnabled });
158
165
  draftSessionEntry(core, { version: 1, mode, seed: trimmed, targetGoalId: targetGoal?.id, startedAt, auditorEnabled });
159
166
  core.clearContinuationState();
@@ -305,7 +312,9 @@ export function registerDraftingTools(core: GoalCore): void {
305
312
  const draft = activeDraft(core);
306
313
  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) };
307
314
  const objective = params.objective.trim();
308
- if (!objective || objective.length > 4000) return { content: [{ type: "text", text: "The proposed objective must be between 1 and 4000 characters." }], details: goalDetails(core.state.goal) };
315
+ if (!objective) return { content: [{ type: "text", text: "The proposed objective must be at least 1 character." }], details: goalDetails(core.state.goal) };
316
+ const objectiveMaxChars = loadGoalSettings(ctx.cwd).objectiveMaxChars ?? 0;
317
+ if (objectiveMaxChars > 0 && objective.length > objectiveMaxChars) return { content: [{ type: "text", text: `The proposed objective must be at most ${objectiveMaxChars} characters (${objective.length} given).` }], details: goalDetails(core.state.goal) };
309
318
  const expectedSisyphus = draft.mode === "sisyphus";
310
319
  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) };
311
320
  const taskResult = proposedTaskList(core, ctx, params.tasks as FlatTaskInput[] | undefined, params.block_completion);
@@ -389,6 +398,11 @@ export function registerDraftingTools(core: GoalCore): void {
389
398
  // keep status/evidence/completedAt/skippedAt/skipReason, new ids start
390
399
  // pending, removed ids drop. currentTaskId survives only while its task
391
400
  // is still pending in the merged tree.
401
+ // §tweak-resume: a confirmed tweak is a deliberate revision of a
402
+ // stalled goal — set when the goal actually transitions out of
403
+ // paused/blocked so the post-apply glue can resume accounting,
404
+ // continuation, and the ledger event.
405
+ let resumed = false;
392
406
  const result = core.goalService.apply(ctx, {
393
407
  reconcile: false, focusToken: token, refreshFromDisk: true,
394
408
  mutate: (goal) => {
@@ -400,13 +414,43 @@ export function registerDraftingTools(core: GoalCore): void {
400
414
  const currentTaskId = mergedTaskList && goal.currentTaskId && !currentTaskIdIsPending(mergedTaskList.tasks, goal.currentTaskId)
401
415
  ? undefined
402
416
  : goal.currentTaskId;
403
- return { ...goal, objective: extracted.objective, verificationContract: extracted.verificationContract ?? goal.verificationContract, taskList, currentTaskId, skipAuditor, updatedAt: now };
417
+ // §tweak-resume: resume a paused/blocked goal (mirror the
418
+ // /goal-resume transition: status active + pause metadata
419
+ // cleared); budget_limited stays behind its hard resource
420
+ // gate and an active goal stays active.
421
+ const stalled = goal.status === "paused" || goal.status === "blocked";
422
+ if (stalled) resumed = true;
423
+ return {
424
+ ...goal,
425
+ objective: extracted.objective,
426
+ verificationContract: extracted.verificationContract ?? goal.verificationContract,
427
+ taskList, currentTaskId, skipAuditor,
428
+ status: stalled ? "active" : goal.status,
429
+ autoContinue: stalled ? true : goal.autoContinue,
430
+ stopReason: stalled ? undefined : goal.stopReason,
431
+ pauseReason: stalled ? undefined : goal.pauseReason,
432
+ pauseSuggestedAction: stalled ? undefined : goal.pauseSuggestedAction,
433
+ updatedAt: now,
434
+ };
404
435
  },
405
436
  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 }] : [])],
406
437
  });
407
438
  if (!result.ok) return { content: [{ type: "text", text: "Goal tweak was not applied: " + result.message }], details: goalDetails(core.state.goal) };
439
+ if (resumed) {
440
+ try {
441
+ core.goalService.appendEvents(ctx, [{ type: "goal_resumed", goalId: result.goal.id, reason: "tweak", at: nowIso() }]);
442
+ } catch {
443
+ // Ledger append failure should not crash the tweak.
444
+ }
445
+ }
408
446
  core.clearContinuationState();
409
447
  core.updateUI(ctx);
448
+ if (resumed) {
449
+ // Resume glue mirrors replaceGoal: restart accounting and queue
450
+ // the auto-continuation so the revived goal keeps going.
451
+ core.beginAccounting();
452
+ core.queueContinuation(ctx, true);
453
+ }
410
454
  clearGoalDrafting(core, ctx);
411
455
  return { content: [{ type: "text", text: `${summary}\n\nGoal tweak confirmed and applied.` }], details: goalDetails(result.goal), terminate: true };
412
456
  },
@@ -11,6 +11,7 @@ export type GoalLedgerEvent =
11
11
  | { type: "goal_paused"; goalId: string; reason: string; suggestedAction?: string; status?: "paused"; source?: "user" | "agent"; at: string }
12
12
  | { type: "goal_resumed"; goalId: string; reason: string; at: string }
13
13
  | { type: "goal_tweaked"; goalId: string; changeSummary: string; at: string }
14
+ | { type: "auditor_toggled"; goalId: string; enabled: boolean; at: string }
14
15
  | { type: "completion_requested"; goalId: string; summary?: string; at: string }
15
16
  | { type: "audit_started"; goalId: string; provider?: string; model?: string; thinkingLevel?: string; at: string }
16
17
  | { type: "audit_result"; goalId: string; verdict: "approved" | "disapproved" | "error"; report: string; at: string }
@@ -224,6 +225,8 @@ function isValidLedgerEvent(value: unknown): value is GoalLedgerEvent {
224
225
  return typeof obj.goalId === "string" && typeof obj.reason === "string";
225
226
  case "goal_tweaked":
226
227
  return typeof obj.goalId === "string" && typeof obj.changeSummary === "string";
228
+ case "auditor_toggled":
229
+ return typeof obj.goalId === "string" && typeof obj.enabled === "boolean";
227
230
  case "completion_requested":
228
231
  return typeof obj.goalId === "string" && (obj.summary === undefined || typeof obj.summary === "string");
229
232
  case "audit_started":
@@ -275,6 +278,8 @@ function sanitizeEvent(event: GoalLedgerEvent): GoalLedgerEvent {
275
278
  return { ...event, goalId: safeGoalId(event.goalId) };
276
279
  case "goal_tweaked":
277
280
  return { ...event, goalId: safeGoalId(event.goalId) };
281
+ case "auditor_toggled":
282
+ return { ...event, goalId: safeGoalId(event.goalId) };
278
283
  case "completion_requested":
279
284
  return { ...event, goalId: safeGoalId(event.goalId) };
280
285
  case "audit_started":
@@ -4,11 +4,12 @@
4
4
  * Reads `.pi/pi-goal-x-settings.json` with env var overrides:
5
5
  * PI_GOAL_DISABLE_TASKS — "true" to disable, any other value = use file config
6
6
  * PI_GOAL_DISABLE_CONTRACTS — "true" to disable, any other value = use file config
7
+ * PI_GOAL_OBJECTIVE_MAX_CHARS — objective length cap (0 = no limit), overrides file
7
8
  * PI_GOAL_SETTINGS_FILE — alternative settings file path (relative to cwd or absolute)
8
9
  *
9
10
  * The file may contain:
10
11
  * disableTasks, disableContracts, subtaskDepth,
11
- * provider, model, thinkingLevel, disabled
12
+ * provider, model, thinkingLevel, disabled, objectiveMaxChars
12
13
  *
13
14
  * additionalProperties: false — unknown keys are rejected.
14
15
  */
@@ -31,6 +32,12 @@ export interface GoalSettings {
31
32
  auditorProjectResources?: boolean;
32
33
  /** F5: stall detector timeout in minutes (0 = off). */
33
34
  stallTimeoutMinutes?: number;
35
+ /**
36
+ * Maximum objective length in characters (0/unset = no limit, the
37
+ * default; >0 caps objectives in create_goal, propose_goal_draft, and
38
+ * /goal-tweak).
39
+ */
40
+ objectiveMaxChars?: number;
34
41
  }
35
42
 
36
43
  export const PI_GOAL_SETTINGS_FILE_ENV = "PI_GOAL_SETTINGS_FILE";
@@ -76,6 +83,7 @@ const ALLOWED_SETTINGS_KEYS = new Set([
76
83
  "autoSelectSingleGoal",
77
84
  "auditorProjectResources",
78
85
  "stallTimeoutMinutes",
86
+ "objectiveMaxChars",
79
87
  ]);
80
88
 
81
89
  /**
@@ -110,6 +118,16 @@ function asPositiveInt(value: unknown): number | undefined {
110
118
  return undefined;
111
119
  }
112
120
 
121
+ /** Positive-integer-or-zero parser (for settings where 0 = off / no limit). */
122
+ function asNonNegativeInt(value: unknown): number | undefined {
123
+ if (typeof value === "number" && Number.isInteger(value) && value >= 0) return value;
124
+ if (typeof value === "string") {
125
+ const n = parseInt(value, 10);
126
+ if (!isNaN(n) && n >= 0) return n;
127
+ }
128
+ return undefined;
129
+ }
130
+
113
131
  function asThinkingLevel(value: unknown): ThinkingLevel | undefined {
114
132
  const text = asNonEmptyString(value);
115
133
  return text && THINKING_LEVELS.has(text) ? text as ThinkingLevel : undefined;
@@ -144,6 +162,8 @@ export function parseGoalSettings(raw: unknown): GoalSettings {
144
162
  if (record.auditorProjectResources === true || record.auditorProjectResources === "true") settings.auditorProjectResources = true;
145
163
  const stallTimeoutMinutes = asPositiveInt(record.stallTimeoutMinutes);
146
164
  if (stallTimeoutMinutes !== undefined) settings.stallTimeoutMinutes = stallTimeoutMinutes;
165
+ const objectiveMaxChars = asNonNegativeInt(record.objectiveMaxChars);
166
+ if (objectiveMaxChars !== undefined) settings.objectiveMaxChars = objectiveMaxChars;
147
167
  return settings;
148
168
  }
149
169
 
@@ -186,6 +206,7 @@ export function loadGoalSettings(cwd: string, env: NodeJS.ProcessEnv = process.e
186
206
  autoSelectSingleGoal: fileConfig.autoSelectSingleGoal ?? false,
187
207
  auditorProjectResources: fileConfig.auditorProjectResources ?? false,
188
208
  stallTimeoutMinutes: fileConfig.stallTimeoutMinutes,
209
+ objectiveMaxChars: asNonNegativeInt(env.PI_GOAL_OBJECTIVE_MAX_CHARS) ?? fileConfig.objectiveMaxChars,
189
210
  };
190
211
  }
191
212
 
@@ -204,6 +225,7 @@ export function loadGoalSettings(cwd: string, env: NodeJS.ProcessEnv = process.e
204
225
  export function envOverrideFor(key: keyof GoalSettings | "settingsFile", env: NodeJS.ProcessEnv = process.env): string | null {
205
226
  if (key === "disableTasks" && env.PI_GOAL_DISABLE_TASKS !== undefined) return "PI_GOAL_DISABLE_TASKS";
206
227
  if (key === "disableContracts" && env.PI_GOAL_DISABLE_CONTRACTS !== undefined) return "PI_GOAL_DISABLE_CONTRACTS";
228
+ if (key === "objectiveMaxChars" && env.PI_GOAL_OBJECTIVE_MAX_CHARS !== undefined) return "PI_GOAL_OBJECTIVE_MAX_CHARS";
207
229
  if (key === "settingsFile" && env[PI_GOAL_SETTINGS_FILE_ENV] !== undefined) return PI_GOAL_SETTINGS_FILE_ENV;
208
230
  return null;
209
231
  }
@@ -227,6 +249,7 @@ export function effectiveSettingsReport(cwd: string, env: NodeJS.ProcessEnv = pr
227
249
  { key: "thinkingLevel", label: "thinking_level", format: (v) => v.thinkingLevel ?? "(default)" },
228
250
  { key: "auditorProjectResources", label: "auditor project resources", format: (v) => (v.auditorProjectResources === true ? "true" : "false") },
229
251
  { key: "stallTimeoutMinutes", label: "stall timeout (minutes)", format: (v) => String(v.stallTimeoutMinutes ?? 0) },
252
+ { key: "objectiveMaxChars", label: "max objective length (0 = none)", format: (v) => String(v.objectiveMaxChars ?? 0) },
230
253
  ];
231
254
  for (const row of rows) {
232
255
  const envVar = envOverrideFor(row.key, env);
@@ -262,6 +285,7 @@ export function saveGoalSettingsFileConfig(cwd: string, settings: GoalSettings):
262
285
  if (settings.autoSelectSingleGoal === true) clean.autoSelectSingleGoal = true;
263
286
  if (settings.auditorProjectResources === true) clean.auditorProjectResources = true;
264
287
  if (settings.stallTimeoutMinutes !== undefined) clean.stallTimeoutMinutes = settings.stallTimeoutMinutes;
288
+ if (settings.objectiveMaxChars !== undefined) clean.objectiveMaxChars = settings.objectiveMaxChars;
265
289
  const configPath = goalSettingsPath(cwd);
266
290
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
267
291
  settingsFileCache.delete(configPath);
@@ -276,6 +300,7 @@ export function saveGoalSettingsFileConfig(cwd: string, settings: GoalSettings):
276
300
  if (settings.autoSelectSingleGoal === true) persisted.autoSelectSingleGoal = true;
277
301
  if (settings.auditorProjectResources === true) persisted.auditorProjectResources = true;
278
302
  if (clean.stallTimeoutMinutes !== undefined) persisted.stallTimeoutMinutes = clean.stallTimeoutMinutes;
303
+ if (clean.objectiveMaxChars !== undefined) persisted.objectiveMaxChars = clean.objectiveMaxChars;
279
304
  fs.writeFileSync(configPath, `${JSON.stringify(persisted, null, 2)}\n`, "utf8");
280
305
  return clean;
281
306
  }
@@ -1,5 +1,4 @@
1
1
  import { type AgentToolResult, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { footerStatus } from "./goal-core.ts";
3
2
  import { FOCUS_ENTRY, STATE_ENTRY, GOAL_EVENT_ENTRY, goalDetails } from "./goal-format.ts";
4
3
  import { loadGoalSettings, loadGoalSettingsFileConfig } from "./goal-settings.ts";
5
4
  import {
@@ -122,6 +121,8 @@ export interface GoalCore {
122
121
  archiveCurrentGoal(ctx: ExtensionContext, reason: StopReason | undefined): GoalRecord | null;
123
122
  stopActiveGoal(status: Exclude<GoalStatus, "active">, reason: StopReason | undefined, ctx: ExtensionContext): void;
124
123
  pauseActiveGoal(ctx: ExtensionContext): void;
124
+ /** §auditor-toggle: flip the focused goal's persisted per-goal skipAuditor and record the ledger event. */
125
+ toggleGoalAuditor(ctx: ExtensionContext): void;
125
126
  queueContinuation(ctx: ExtensionContext, force?: boolean): void;
126
127
  flushGoalTransaction(ctx: ExtensionContext): void;
127
128
  replaceGoal(config: GoalCreationConfig, ctx: ExtensionContext, startNow?: boolean, verificationContract?: string, tokenBudget?: number): void;
@@ -669,9 +670,12 @@ export function createGoalCore(
669
670
  return;
670
671
  }
671
672
 
672
- const displayGoal = goalForDisplay() ?? state.goal;
673
- const otherCount = otherOpenGoalCount(goalsById, focusedGoalId);
674
- ctx.ui.setStatus("goal", `${footerStatus(displayGoal)}${otherCount > 0 ? ` (+${otherCount} open)` : ""}`);
673
+ // The goal widget is the single home for goal status: the status line
674
+ // lives in the compact/expanded dashboard (goal: <label> [<usage>]
675
+ // (+N open)), so the focused-goal footer segment is cleared — a stale
676
+ // unfocused hint or earlier status must not linger once a goal is
677
+ // focused. The unfocused branch above still sets its own hint.
678
+ ctx.ui.setStatus("goal", undefined);
675
679
 
676
680
  if (!widgetRegistered) {
677
681
  ctx.ui.setWidget(
@@ -808,6 +812,38 @@ export function createGoalCore(
808
812
  ctx.ui.notify("Goal paused.", "info");
809
813
  }
810
814
 
815
+ /**
816
+ * §auditor-toggle: flip the focused goal's independent-auditor setting.
817
+ * Persisted per-goal (revision-safe via goalService.apply), recorded as an
818
+ * auditor_toggled ledger event, reflected in the dashboard, and announced.
819
+ * Inert when no goal is focused or the goal is complete; the widget's
820
+ * modal-depth guard keeps it inert while a goal modal is open.
821
+ */
822
+ function toggleGoalAuditor(ctx: ExtensionContext): void {
823
+ if (!state.goal) {
824
+ ctx.ui.notify("No focused goal to toggle the auditor for.", "info");
825
+ return;
826
+ }
827
+ if (state.goal.status === "complete") {
828
+ ctx.ui.notify("This goal is complete; the auditor no longer applies.", "info");
829
+ return;
830
+ }
831
+ const nextEnabled = state.goal.skipAuditor === true;
832
+ const result = goalService.apply(ctx, {
833
+ reconcile: false,
834
+ refreshFromDisk: true,
835
+ mutate: (g) => ({ ...g, skipAuditor: g.skipAuditor === true ? undefined : true, updatedAt: nowIso() }),
836
+ ledger: (written) => [{ type: "auditor_toggled" as const, goalId: written.id, enabled: nextEnabled, at: written.updatedAt }],
837
+ });
838
+ if (!result.ok) {
839
+ ctx.ui.notify("Could not toggle the auditor: " + result.message, "error");
840
+ return;
841
+ }
842
+ goalService.flushTurn(ctx); // P1-3: user-visible setting change persists now, not at turn end
843
+ updateUI(ctx);
844
+ ctx.ui.notify(nextEnabled ? "Auditor enabled for this goal." : "Auditor disabled for this goal.", "info");
845
+ }
846
+
811
847
  function flushGoalTransaction(ctx: ExtensionContext): void {
812
848
  goalService.flushTurn(ctx);
813
849
  }
@@ -992,6 +1028,7 @@ export function createGoalCore(
992
1028
  archiveCurrentGoal,
993
1029
  stopActiveGoal,
994
1030
  pauseActiveGoal,
1031
+ toggleGoalAuditor,
995
1032
  queueContinuation,
996
1033
  flushGoalTransaction,
997
1034
  touchGoalActivity,
@@ -162,6 +162,16 @@ export function syncTerminalInputPause(core: GoalCore, ctx: ExtensionContext): v
162
162
  return { consume: true };
163
163
  }
164
164
 
165
+ // Ctrl+Shift+A — toggle the focused goal's independent auditor.
166
+ // Persisted per-goal (revision-safe), ledger event, dashboard
167
+ // refresh, notification; inert with no focused goal or a complete
168
+ // goal (the modal-depth guard above covers goal modals).
169
+ if (matchesKey(data, "ctrl+shift+a")) {
170
+ core.toggleGoalAuditor(ctx);
171
+ core.goalWidgetComponentRef.current?.invalidate();
172
+ return { consume: true };
173
+ }
174
+
165
175
  // Navigation keys: plain arrows scroll the expanded dashboard (it is
166
176
  // modal while open); Ctrl+Shift+↑/↓/PgUp/PgDn/Home/End scroll the
167
177
  // compact task list — free chords, consumed only when the compact