pi-goal-x 0.25.2 → 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
@@ -260,9 +260,13 @@ Always visible above the editor while a goal is focused:
260
260
  │ Current t3 · Add the download button │
261
261
  │ Verify Run npm test with zero failures. │
262
262
  │ File .pi/goals/active_goal_... │
263
- ╰─ Ctrl+Shift+T: expand tasks ───────────────────────────────────────╯
263
+ ╰─ Ctrl+Shift+T: expand tasks · Ctrl+Shift+A ──────────────────── ● ─╯
264
264
  ```
265
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
+
266
270
  ### Expanded mode
267
271
 
268
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.
@@ -351,7 +355,7 @@ Settings are stored in:
351
355
  .pi/pi-goal-x-settings.json
352
356
  ```
353
357
 
354
- 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`.
355
359
 
356
360
  ## License
357
361
 
@@ -32,7 +32,7 @@ Always visible above the editor while a goal is focused:
32
32
  │ Current t3 · Add the download button │
33
33
  │ Verify Run npm test with zero failures. │
34
34
  │ File .pi/goals/active_goal_g1.md │
35
- ╰─ Ctrl+Shift+T: expand tasks ───────────────────────────────────────────────────────────╯
35
+ ╰─ Ctrl+Shift+T: expand tasks · Ctrl+Shift+A ──────────────────────────────────────── ● ─╯
36
36
  ```
37
37
 
38
38
  Compact rows, in order:
@@ -52,7 +52,7 @@ Compact rows, in order:
52
52
  tasks. The subtask segment appears only when the current task has direct
53
53
  children. The whole header row is neutral gray (label, counts, bar fill,
54
54
  and empty cells) so nothing in the progress info is colourful.
55
- 5. Task list section: the top-level tasks shown by default in **pastel
55
+ 6. Task list section: the top-level tasks shown by default in **pastel
56
56
  amber** (`mdHeading`) with colour-coded markers and ids (✓ complete muted
57
57
  green, ▸ current teal, ~ skipped gray, · pending amber), an aligned id
58
58
  column, truncated amber titles, and a `… +N more` overflow line when the
@@ -72,7 +72,15 @@ Compact rows, in order:
72
72
  shortcut hint and trailing fill — is drawn in one frame tone (the
73
73
  neutral-gray `muted`), so the color spans the full width with no two-tone
74
74
  split; the header line is likewise one tone with only the `pi-goal-x`
75
- 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.
76
84
 
77
85
  When every top-level task is done, the current line reads
78
86
  `Current All tasks complete`. With `disableTasks` enabled the task rows are
@@ -212,6 +220,7 @@ auditor's findings, and returns to the normal dashboard so work can continue.
212
220
  | Key | Action |
213
221
  | --- | --- |
214
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) |
215
224
  | `↑` / `↓`, `PgUp` / `PgDn`, `Home` / `End` | Scroll the expanded task tree (see [Scrolling the task list](#scrolling-the-task-list)) |
216
225
  | `Ctrl+Shift+↑` / `Ctrl+Shift+↓`, `Ctrl+Shift+PgUp` / `Ctrl+Shift+PgDn`, `Ctrl+Shift+Home` / `Ctrl+Shift+End` | Scroll the compact task list |
217
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
  }
@@ -121,6 +121,8 @@ export interface GoalCore {
121
121
  archiveCurrentGoal(ctx: ExtensionContext, reason: StopReason | undefined): GoalRecord | null;
122
122
  stopActiveGoal(status: Exclude<GoalStatus, "active">, reason: StopReason | undefined, ctx: ExtensionContext): void;
123
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;
124
126
  queueContinuation(ctx: ExtensionContext, force?: boolean): void;
125
127
  flushGoalTransaction(ctx: ExtensionContext): void;
126
128
  replaceGoal(config: GoalCreationConfig, ctx: ExtensionContext, startNow?: boolean, verificationContract?: string, tokenBudget?: number): void;
@@ -810,6 +812,38 @@ export function createGoalCore(
810
812
  ctx.ui.notify("Goal paused.", "info");
811
813
  }
812
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
+
813
847
  function flushGoalTransaction(ctx: ExtensionContext): void {
814
848
  goalService.flushTurn(ctx);
815
849
  }
@@ -994,6 +1028,7 @@ export function createGoalCore(
994
1028
  archiveCurrentGoal,
995
1029
  stopActiveGoal,
996
1030
  pauseActiveGoal,
1031
+ toggleGoalAuditor,
997
1032
  queueContinuation,
998
1033
  flushGoalTransaction,
999
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
@@ -34,6 +34,8 @@ export interface GoalDashboardModel {
34
34
  };
35
35
 
36
36
  focused: boolean;
37
+ /** §auditor-toggle: whether the independent completion auditor is enabled for this goal (derived from the goal's persisted skipAuditor; undefined = on). */
38
+ auditorEnabled: boolean;
37
39
  filePath?: string;
38
40
 
39
41
  usage: {
@@ -498,6 +500,7 @@ export function deriveGoalDashboardModel(
498
500
  title: displayObjectiveTitle(goal.objective),
499
501
  status,
500
502
  focused,
503
+ auditorEnabled: goal.skipAuditor !== true,
501
504
  filePath: goal.activePath ?? goal.archivedPath,
502
505
  usage: {
503
506
  activeSeconds: goal.usage.activeSeconds,
@@ -96,16 +96,20 @@ function boxSectionRule(theme: Theme, width: number, label: string, right = ""):
96
96
  return `${frame(theme, "├")}${l}${frame(theme, H.repeat(fill))}${r}${frame(theme, "┤")}`;
97
97
  }
98
98
 
99
- function boxFooter(theme: Theme, width: number, content: string): string {
99
+ function boxFooter(theme: Theme, width: number, content: string, right = ""): string {
100
100
  const inner = Math.max(4, width - 2);
101
- if (!content) {
101
+ // Optional right slot mirrors boxHeader's right-meta treatment (` ● ─`
102
+ // before the corner), so the auditor dot sits at the bottom-right of the
103
+ // border. The dot survives; the hint (left content) truncates when tight.
104
+ const r = right ? ` ${right} ${frame(theme, H)}` : "";
105
+ if (!content && !r) {
102
106
  return frame(theme, `╰${H.repeat(inner)}╯`);
103
107
  }
104
108
  // The footer is one frame tone: leading dash, hint text and trailing fill
105
109
  // all carry the frame color, so the blue-gray spans the whole line.
106
- const l = frame(theme, `${H} ${fit(content, inner - 4)}`);
107
- const fill = Math.max(1, inner - visibleWidth(l));
108
- return `${frame(theme, "╰")}${l}${frame(theme, H.repeat(fill))}${frame(theme, "╯")}`;
110
+ const l = content ? frame(theme, `${H} ${fit(content, Math.max(0, inner - 4 - visibleWidth(r)))}`) : "";
111
+ const fill = Math.max(1, inner - visibleWidth(l) - visibleWidth(r));
112
+ return `${frame(theme, "╰")}${l}${frame(theme, H.repeat(fill))}${r}${frame(theme, "╯")}`;
109
113
  }
110
114
 
111
115
  // ── §5.2 status colors ──────────────────────────────────────────────────────
@@ -311,6 +315,10 @@ export function renderCompactDashboard(
311
315
  lines.push(boxLine(theme, safeWidth, `${theme.fg(fuel as RenderColor, "⛽")} ${muted(theme, "Budget")} ${theme.fg(fuel as RenderColor, formatBudget(model.budget.used, model.budget.total))}`));
312
316
  }
313
317
 
318
+ // §auditor-toggle: the focused goal's independent-auditor status is the
319
+ // bottom-right border dot (green on / muted gray off, see the footer
320
+ // below); the Ctrl+Shift+A chord is advertised in wide/medium footers.
321
+
314
322
  // §9.1 compact task counts + progress bars live in the Tasks header row
315
323
  // (below); the standalone progress lines were removed in favor of it.
316
324
 
@@ -388,15 +396,21 @@ export function renderCompactDashboard(
388
396
  lines.push(boxLine(theme, safeWidth, `${dim(theme, `File ${model.filePath}`)}`));
389
397
  }
390
398
 
391
- const footerHint = opts.footerHint
399
+ const footerHint = (opts.footerHint
392
400
  ?? (listOverflows
393
401
  ? mode === "minimal"
394
402
  ? "↑↓: scroll"
395
403
  : mode === "narrow"
396
404
  ? "Ctrl+Shift+↑↓: scroll"
397
405
  : "Ctrl+Shift+T: expand · Ctrl+Shift+↑↓: scroll"
398
- : spec.footerHint);
399
- lines.push(boxFooter(theme, safeWidth, footerHint));
406
+ : spec.footerHint))
407
+ // §auditor-toggle: wide/medium footers also advertise the Ctrl+Shift+A
408
+ // toggle; narrow/minimal keep only the border dot.
409
+ + (mode === "wide" || mode === "medium" ? " · Ctrl+Shift+A" : "");
410
+ // The auditor dot lives in the bottom-right of the border: green when the
411
+ // focused goal's independent auditor is on, muted gray when off.
412
+ const auditorDot = model.auditorEnabled ? success(theme, "●") : muted(theme, "●");
413
+ lines.push(boxFooter(theme, safeWidth, footerHint, auditorDot));
400
414
  return lines;
401
415
  }
402
416
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-x",
3
- "version": "0.25.2",
3
+ "version": "0.25.3",
4
4
  "description": "Goal mode extension for pi: durable long-running objectives, five model tools, structured tasks, independent completion audit, Sisyphus mode, auto-continue, and a status overlay.",
5
5
  "license": "MIT",
6
6
  "author": "pi-goal-x contributors",
@@ -26,8 +26,14 @@
26
26
  "files": [
27
27
  "extensions",
28
28
  "README.md",
29
- "docs",
30
- "LICENSE"
29
+ "LICENSE",
30
+ "docs/agent-flow-design.md",
31
+ "docs/agentic-runtime-prd.md",
32
+ "docs/architecture.md",
33
+ "docs/goal-ts-refactor-test-strategy.md",
34
+ "docs/pi-autoresearch-survey.md",
35
+ "docs/pi-goal-x-unified-dashboard-plan.md",
36
+ "docs/unified-dashboard.md"
31
37
  ],
32
38
  "pi": {
33
39
  "extensions": [
@@ -1 +0,0 @@
1
- {"version":1,"dirMtimeMs":1786087947377.8738,"goals":[]}