killeros 2.1.22 → 2.1.23

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/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ All notable changes to KillerOS are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.1.23] - 2026-09-04
8
+
9
+ ### Changed
10
+
11
+ - Simplified `/goal` to five forms with proof-by-prose and silent file proof, removing configured completion checks.
12
+
13
+ ### Fixed
14
+
15
+ - Kept the automatic 20-turn goal pause silent as documented.
16
+ - Stripped sentence punctuation from unquoted inferred goal paths and picked token units after rounding so 999.6 shows as 1k and 999999 as 1M.
17
+ - Increased change-receipt Git timeouts to five seconds and stopped timeout failures from showing warning notifications.
18
+
7
19
  ## [2.1.22] - 2026-09-03
8
20
 
9
21
  ### Added
package/README.md CHANGED
@@ -5,7 +5,7 @@ A TypeScript extension for the [Pi coding agent](https://github.com/earendil-wor
5
5
  ## What you get
6
6
 
7
7
  - A custom TUI: startup card with version, model, provider, working directory, and Git branch; a dark theme with coral accents; a multiline editor with slash-command completion; a footer that tracks model, context, and goal state; settled task receipts with duration and token usage.
8
- - `/goal`: set an objective and Pi keeps working toward it across turns, compaction, reloads, and branch navigation. New goals default to 20 turns. Named checks and turn limits control completion and unattended work.
8
+ - `/goal`: set an objective and Pi keeps working toward it across turns, compaction, reloads, and branch navigation. New goals pause after 20 turns; `/goal resume` grants another 20.
9
9
  - `/init`: generates a root `AGENTS.md` from repository evidence, preserving compatible existing rules.
10
10
  - `/variants`: pick a reasoning level supported by the active model.
11
11
  - `/codex-fast`: toggles the `priority` service tier on Codex requests.
@@ -34,19 +34,17 @@ Or from GitHub:
34
34
  pi install git:github.com/KyrosHendrix/pi-KillerOS
35
35
  ```
36
36
 
37
- Pin a release by appending its tag, for example `@v2.1.22`. Add `-l` to install only for the current project. Restart Pi after installing.
37
+ Pin a release by appending its tag, for example `@v2.1.23`. Add `-l` to install only for the current project. Restart Pi after installing.
38
38
 
39
39
  ## Commands
40
40
 
41
41
  ```text
42
42
  /init Generate root AGENTS.md from repository evidence
43
- /goal Open status, or set with /goal <objective>
44
- /goal start [--check name] [--turns count] -- <objective>
45
- /goal check <name|clear> Set or clear a named completion check
46
- /goal checks List configured completion-check names
47
- /goal limit <count|clear> Set a limit or clear it for unlimited turns
48
- /goal history [count] Show transitions and blocker evidence on this branch
49
- /goal edit|pause|resume|clear
43
+ /goal View the current goal
44
+ /goal <objective> Set an objective
45
+ /goal pause Stop automatic continuation
46
+ /goal resume Resume automatic continuation
47
+ /goal clear Remove the current goal
50
48
  /variants Reasoning-level selector (/variants high sets directly)
51
49
  /codex-fast Toggle Codex fast mode
52
50
  /notification Configure the completion sound
@@ -60,7 +58,7 @@ Pin a release by appending its tag, for example `@v2.1.22`. Add `-l` to install
60
58
  | Mode | What works |
61
59
  | --- | --- |
62
60
  | TUI | Everything |
63
- | RPC | Goals, proactive compaction; no TUI components, `/goal edit`, `/init`, sounds, title indicator |
61
+ | RPC | Goals, proactive compaction; no TUI components, `/init`, sounds, title indicator |
64
62
  | Print/JSON | No interactive questions, `/goal`, `/init`, or proactive compaction |
65
63
 
66
64
  ## Configuration
@@ -79,22 +77,19 @@ The packaged `killeros` theme activates on TUI start. Compaction triggers by def
79
77
 
80
78
  `handoffMaxTokens` caps the `/handoff` summary output at 8192 tokens by default; raise it when long sessions truncate the summary.
81
79
 
82
- Trusted projects can define up to 32 named goal checks in `.pi/killeros-hooks.json`:
80
+ State proof in the objective so the agent can verify it with its normal tools:
83
81
 
84
- ```json
85
- {
86
- "goalChecks": {
87
- "quality": {
88
- "command": "npm run check && npm test",
89
- "timeoutMs": 300000
90
- }
91
- }
92
- }
82
+ ```text
83
+ /goal Reduce p95 checkout latency below 120 ms, verified by the checkout benchmark, while keeping the correctness suite green
93
84
  ```
94
85
 
95
- Use `/goal checks` to list configured names. Use `/goal start -- <objective>` when an objective begins with `start`, `check`, `checks`, `limit`, `history`, `clear`, `edit`, `pause`, or `resume`. Bind a check with `/goal start --check quality -- <objective>` or `/goal check quality`. KillerOS stores the check name and definition hash, not the command. If the project changes the command or timeout, bind the check again before completing the goal.
86
+ A direct quoted file target binds silent file proof:
87
+
88
+ ```text
89
+ /goal Fix `killeros/footer.ts`, verified by npm test
90
+ ```
96
91
 
97
- New goals use a 20-turn limit unless `/goal start --turns <count> -- <objective>` supplies another value. Use `/goal limit <count>` to change the current goal or `/goal limit clear` to allow unlimited turns. Restored goals keep their persisted limit.
92
+ KillerOS captures the file baseline at goal start and only completes when the file is created or changed. New goals pause after 20 turns without warning. An explicit `/goal resume` on an exhausted goal grants another 20 turns; compaction recovery never grants turns. Restored goals keep their persisted limit.
98
93
 
99
94
  Completion sounds are off by default; change with `/notification` in TUI mode. The tab-title indicator requires a Nerd Font.
100
95
 
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { inflate } from "node:zlib";
7
7
 
8
- const GIT_TIMEOUT_MS = 1_000;
8
+ const GIT_TIMEOUT_MS = 5_000;
9
9
  const GIT_OUTPUT_LIMIT = 16 * 1024 * 1024;
10
10
  const SNAPSHOT_CONTENT_LIMIT = 128 * 1024 * 1024;
11
11
  const MAX_DIFF_OPERATIONS = 500_000;
@@ -70,7 +70,7 @@ const BUILTIN_COMMANDS: ReadonlyArray<{ name: string; description: string }> = [
70
70
  ];
71
71
 
72
72
  const COMMAND_SYNTAX_HINTS: Readonly<Record<string, string>> = {
73
- goal: "/goal [objective|start|check|checks|limit|history|clear|edit|pause|resume]",
73
+ goal: "/goal [objective|pause|resume|clear]",
74
74
  handoff: "/handoff [next-session focus]",
75
75
  variants: "/variants [level]",
76
76
  model: "/model [provider/model]",
@@ -40,12 +40,14 @@ export function formatTime(milliseconds: number): string {
40
40
 
41
41
  export function formatTokens(value: number): string {
42
42
  if (!Number.isFinite(value)) return "0";
43
- const amount = Math.max(0, value);
44
- if (amount < 1_000) return `${Math.round(amount)}`;
45
- if (amount >= 1_000_000) {
46
- const precision = amount >= 10_000_000 ? 0 : 1;
47
- return `${Number((amount / 1_000_000).toFixed(precision))}M`;
43
+ const rounded = Math.round(Math.max(0, value));
44
+ if (rounded < 1_000) return `${rounded}`;
45
+ if (rounded >= 1_000_000) {
46
+ const precision = rounded >= 10_000_000 ? 0 : 1;
47
+ return `${Number((rounded / 1_000_000).toFixed(precision))}M`;
48
48
  }
49
- const precision = amount >= 100_000 ? 0 : 1;
50
- return `${Number((amount / 1_000).toFixed(precision))}k`;
49
+ const precision = rounded >= 100_000 ? 0 : 1;
50
+ const inK = Number((rounded / 1_000).toFixed(precision));
51
+ if (inK >= 1_000) return `${Number((rounded / 1_000_000).toFixed(1))}M`;
52
+ return `${inK}k`;
51
53
  }
@@ -1,120 +1,23 @@
1
- import { parseArgs } from "node:util";
2
- import { GOAL_CHECK_NAME_PATTERN, GOAL_MAX_TURNS, validateGoalObjective } from "./goal-state.ts";
3
-
4
- const GOAL_START_USAGE = "Usage: /goal start [--check <name>] [--turns <count>] -- <objective>";
5
- const GOAL_CHECK_USAGE = "Usage: /goal check <name|clear>";
6
- const GOAL_CHECKS_USAGE = "Usage: /goal checks";
7
- const GOAL_LIMIT_USAGE = "Usage: /goal limit <count|clear>";
8
- const GOAL_HISTORY_USAGE = "Usage: /goal history [count]";
9
- const RESERVED_OBJECTIVE_USAGE = "Objective begins with a reserved goal command. Use /goal start -- <objective>.";
10
-
11
- interface ControlledGoalStart {
12
- objective: string;
13
- completionCheckName?: string;
14
- maxTurns?: number;
15
- }
1
+ import { validateGoalObjective } from "./goal-state.ts";
16
2
 
17
3
  export type GoalCommand =
18
4
  | { kind: "status" }
19
5
  | { kind: "objective"; objective: string }
20
- | { kind: "start"; objective: string; completionCheckName?: string; maxTurns?: number }
21
- | { kind: "check"; value: { kind: "clear" } | { kind: "named"; name: string } }
22
- | { kind: "checks" }
23
- | { kind: "limit"; value: { kind: "clear" } | { kind: "count"; count: number } }
24
- | { kind: "history"; count: number }
25
- | { kind: "clear" }
26
- | { kind: "edit" }
27
6
  | { kind: "pause" }
28
7
  | { kind: "resume" }
8
+ | { kind: "clear" }
29
9
  | { kind: "invalid"; message: string };
30
10
 
31
- const RESERVED_GOAL_WORDS = ["start", "check", "checks", "limit", "history", "clear", "edit", "pause", "resume"] as const;
32
-
33
- function parseBoundedInteger(value: string, maximum: number): number | undefined {
34
- if (!/^[1-9][0-9]*$/u.test(value)) return undefined;
35
- const parsed = Number(value);
36
- return Number.isSafeInteger(parsed) && parsed <= maximum ? parsed : undefined;
37
- }
38
-
39
- function parseControlledGoalStart(input: string): ControlledGoalStart | undefined {
40
- const separator = input.startsWith("-- ") ? 0 : input.indexOf(" -- ");
41
- if (separator < 0) return undefined;
42
- const optionText = input.slice(0, separator).trim();
43
- const objective = validateGoalObjective(input.slice(separator + (separator === 0 ? 3 : 4)));
44
- if (!objective) return undefined;
45
- const tokens = optionText ? optionText.split(/\s+/u) : [];
46
- if (tokens.filter((token) => token === "--check" || token.startsWith("--check=")).length > 1
47
- || tokens.filter((token) => token === "--turns" || token.startsWith("--turns=")).length > 1) return undefined;
48
- try {
49
- const parsed = parseArgs({
50
- args: tokens,
51
- options: { check: { type: "string" }, turns: { type: "string" } },
52
- strict: true,
53
- allowPositionals: false,
54
- });
55
- const check = parsed.values.check;
56
- const turns = parsed.values.turns;
57
- if (check !== undefined && !GOAL_CHECK_NAME_PATTERN.test(check)) return undefined;
58
- const maxTurns = turns === undefined ? undefined : parseBoundedInteger(turns, GOAL_MAX_TURNS);
59
- if (turns !== undefined && maxTurns === undefined) return undefined;
60
- return {
61
- objective,
62
- ...(check === undefined ? {} : { completionCheckName: check }),
63
- ...(maxTurns === undefined ? {} : { maxTurns }),
64
- };
65
- } catch {
66
- return undefined;
67
- }
68
- }
69
-
70
11
  /** Parses one raw /goal argument string into a closed command variant. */
71
12
  export function parseGoalCommand(args: string): GoalCommand {
72
13
  const input = args.trim();
73
14
  if (!input) return { kind: "status" };
74
- const parts = input.split(/\s+/u);
75
- const rawFirstWord = parts[0] ?? "";
76
- const firstWord = RESERVED_GOAL_WORDS.find((word) => word === rawFirstWord.toLowerCase());
77
- if (!firstWord) {
78
- const objective = validateGoalObjective(input);
79
- return objective
80
- ? { kind: "objective", objective }
81
- : { kind: "invalid", message: "A goal objective may not exceed 4,000 characters" };
82
- }
83
-
84
- if (firstWord === "start") {
85
- const start = parseControlledGoalStart(input.slice(rawFirstWord.length).trimStart());
86
- if (start) return { kind: "start", ...start };
87
- return { kind: "invalid", message: rawFirstWord === firstWord ? GOAL_START_USAGE : RESERVED_OBJECTIVE_USAGE };
88
- }
89
- if (firstWord === "check") {
90
- const value = parts[1];
91
- if (parts.length === 2 && value?.toLowerCase() === "clear") return { kind: "check", value: { kind: "clear" } };
92
- if (parts.length === 2 && value && GOAL_CHECK_NAME_PATTERN.test(value)) return { kind: "check", value: { kind: "named", name: value } };
93
- return { kind: "invalid", message: rawFirstWord !== firstWord && parts.length > 2 ? RESERVED_OBJECTIVE_USAGE : GOAL_CHECK_USAGE };
94
- }
95
- if (firstWord === "checks") {
96
- return parts.length === 1
97
- ? { kind: "checks" }
98
- : { kind: "invalid", message: rawFirstWord === firstWord ? GOAL_CHECKS_USAGE : RESERVED_OBJECTIVE_USAGE };
99
- }
100
- if (firstWord === "limit") {
101
- const value = parts[1];
102
- if (parts.length === 2 && value?.toLowerCase() === "clear") return { kind: "limit", value: { kind: "clear" } };
103
- const count = parts.length === 2 && value ? parseBoundedInteger(value, GOAL_MAX_TURNS) : undefined;
104
- if (count !== undefined) return { kind: "limit", value: { kind: "count", count } };
105
- return { kind: "invalid", message: rawFirstWord !== firstWord && parts.length > 2 ? RESERVED_OBJECTIVE_USAGE : GOAL_LIMIT_USAGE };
106
- }
107
- if (firstWord === "history") {
108
- if (parts.length === 1) return { kind: "history", count: 20 };
109
- const count = parts.length === 2 && parts[1] ? parseBoundedInteger(parts[1], 50) : undefined;
110
- if (count !== undefined) return { kind: "history", count };
111
- return { kind: "invalid", message: rawFirstWord !== firstWord && parts.length > 2 ? RESERVED_OBJECTIVE_USAGE : GOAL_HISTORY_USAGE };
112
- }
113
- if (parts.length === 1) return { kind: firstWord };
114
- return {
115
- kind: "invalid",
116
- message: rawFirstWord === firstWord
117
- ? `Usage: /goal ${firstWord}`
118
- : RESERVED_OBJECTIVE_USAGE,
119
- };
15
+ const lowered = input.toLowerCase();
16
+ if (lowered === "pause") return { kind: "pause" };
17
+ if (lowered === "resume") return { kind: "resume" };
18
+ if (lowered === "clear") return { kind: "clear" };
19
+ const objective = validateGoalObjective(input);
20
+ return objective
21
+ ? { kind: "objective", objective }
22
+ : { kind: "invalid", message: "A goal objective may not exceed 4,000 characters" };
120
23
  }
@@ -6,11 +6,9 @@ import { BoundedText } from "./bounded-text.ts";
6
6
  import { formatTime, formatTokens } from "./display.ts";
7
7
  import { reportError } from "./errors.ts";
8
8
  import { parseGoalCommand } from "./goal-command.ts";
9
- import { formatGoalHistory } from "./goal-history.ts";
10
- import { GOAL_ENTRY_TYPE, GOAL_UPDATE_TOOL, goalBranchEntries, isGoalModeSupported, isSavedSession, pauseGoalAfterFailure, persistGoalState, scheduleGoalContinuation, stopGoalRun, sumGoalTokens, syncGoalUpdateTool, transitionGoal, type GoalEntryData } from "./goal-runtime.ts";
11
- import { checkpointPausedGoalState, createNewGoalState, DEFAULT_GOAL_MAX_TURNS, editGoalState, goalElapsedMilliseconds, GOAL_VERSION, inferGoalVerification, parseGoalState, pauseGoalState, recordGoalBlockerAudit, updateGoalControlsState, validateGoalObjective, verifyGoalDeliverable } from "./goal-state.ts";
12
- import { listGoalCompletionChecks, resolveGoalCompletionCheck, runGoalCompletionCheck } from "./hooks.ts";
13
- import type { GoalCompletionCheck, GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
9
+ import { GOAL_ENTRY_TYPE, GOAL_UPDATE_TOOL, isGoalModeSupported, isSavedSession, pauseGoalAfterFailure, persistGoalState, scheduleGoalContinuation, stopGoalRun, sumGoalTokens, syncGoalUpdateTool, transitionGoal, type GoalEntryData } from "./goal-runtime.ts";
10
+ import { checkpointPausedGoalState, createNewGoalState, DEFAULT_GOAL_MAX_TURNS, GOAL_MAX_TURNS, goalElapsedMilliseconds, GOAL_VERSION, inferGoalVerification, parseGoalState, recordGoalBlockerAudit, transitionGoalState, verifyGoalDeliverable } from "./goal-state.ts";
11
+ import type { GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
14
12
  import { safeTerminalText } from "./safe-terminal-text.ts";
15
13
 
16
14
  const GoalUpdateParams = Type.Object({
@@ -33,7 +31,7 @@ const GoalUpdateParams = Type.Object({
33
31
  interface GoalUpdateDetails {
34
32
  status: "complete" | "blocked" | "blocker-audit";
35
33
  evidence: string;
36
- verification?: "file" | "check" | "file-and-check" | "model-reported";
34
+ verification?: "file" | "model-reported";
37
35
  blockerKey?: string;
38
36
  streak?: number;
39
37
  }
@@ -42,17 +40,12 @@ function goalStatusLabel(status: GoalStatus): string {
42
40
  return `${status.charAt(0).toUpperCase()}${status.slice(1)}`;
43
41
  }
44
42
 
45
- function goalPanelActions(status: GoalStatus): Array<{ label: string; control: "checks" | "pause" | "resume" | "edit" | "clear" }> {
46
- const terminal = [
47
- { label: "List completion checks", control: "checks" as const },
48
- { label: "Edit objective", control: "edit" as const },
49
- { label: "Clear goal", control: "clear" as const },
50
- ];
51
- if (status === "active") return [{ label: "Pause automatic continuation", control: "pause" }, ...terminal];
43
+ function goalPanelActions(status: GoalStatus): Array<{ label: string; control: "pause" | "resume" | "clear" }> {
44
+ if (status === "active") return [{ label: "Pause automatic continuation", control: "pause" }, { label: "Clear goal", control: "clear" }];
52
45
  if (status === "paused" || status === "blocked") {
53
- return [{ label: "Resume automatic continuation", control: "resume" }, ...terminal];
46
+ return [{ label: "Resume automatic continuation", control: "resume" }, { label: "Clear goal", control: "clear" }];
54
47
  }
55
- return terminal;
48
+ return [{ label: "Clear goal", control: "clear" }];
56
49
  }
57
50
 
58
51
  function goalStatusSummary(state: GoalState, ctx: ExtensionContext): string {
@@ -62,7 +55,7 @@ function goalStatusSummary(state: GoalState, ctx: ExtensionContext): string {
62
55
  : `${state.turns}/${state.maxTurns} turns`;
63
56
  const lines = [
64
57
  `Goal ${goalStatusLabel(state.status).toLowerCase()} · ${turns} · ${formatTime(goalElapsedMilliseconds(state, Date.now()))} · ${formatTokens(usedTokens)} tokens`,
65
- ...(state.completionCheck === undefined ? [] : [`Check: ${state.completionCheck.name}`]),
58
+ ...(state.verification === undefined ? [] : [`Deliverable: ${state.verification.path}`]),
66
59
  state.objective,
67
60
  ];
68
61
  if (state.result) lines.push(state.result);
@@ -105,21 +98,13 @@ export function registerGoalInterface(
105
98
  if (!evidence) throw new Error("Goal evidence must not be empty");
106
99
  if (params.status === "complete") {
107
100
  if (state.verification) await verifyGoalDeliverable(state.verification);
108
- if (state.completionCheck) await runGoalCompletionCheck(ctx, state.completionCheck, signal);
109
- if (state.verification && state.completionCheck) await verifyGoalDeliverable(state.verification);
110
101
  if (runtime.state !== state) throw new Error("Goal changed while completion was being verified");
111
- const verification = state.verification && state.completionCheck
112
- ? "file-and-check"
113
- : state.verification ? "file" : state.completionCheck ? "check" : "model-reported";
102
+ const verification = state.verification ? "file" : "model-reported";
114
103
  transitionGoal(pi, runtime, "complete", "complete", evidence, { resetBlockedAudit: true });
115
104
  const safeEvidence = safeTerminalText(evidence);
116
- const text = state.verification && state.completionCheck
117
- ? `Goal verified complete by file and ${state.completionCheck.name}: ${safeEvidence}`
118
- : state.completionCheck
119
- ? `Goal verified complete by ${state.completionCheck.name}: ${safeEvidence}`
120
- : state.verification
121
- ? `Goal verified complete at ${safeTerminalText(state.verification.path)}: ${safeEvidence}`
122
- : `Goal marked complete (model-reported): ${safeEvidence}`;
105
+ const text = state.verification
106
+ ? `Goal verified complete at ${safeTerminalText(state.verification.path)}: ${safeEvidence}`
107
+ : `Goal marked complete (model-reported): ${safeEvidence}`;
123
108
  return {
124
109
  content: [{ type: "text", text }],
125
110
  details: { status: "complete", evidence, verification },
@@ -198,99 +183,6 @@ export function registerGoalInterface(
198
183
  return;
199
184
  }
200
185
 
201
- if (command.kind === "history") {
202
- const history = formatGoalHistory(goalBranchEntries(ctx), command.count);
203
- ctx.ui.notify(history ?? "No goal history on the current branch.", "info");
204
- return;
205
- }
206
-
207
- if (command.kind === "checks") {
208
- try {
209
- const checks = listGoalCompletionChecks(ctx);
210
- ctx.ui.notify(checks.length
211
- ? `Goal completion checks: ${checks.join(", ")}`
212
- : "No goal completion checks are configured.", "info");
213
- } catch (error) {
214
- reportError(ctx, "Goal completion checks could not be listed", error);
215
- }
216
- return;
217
- }
218
-
219
- if (command.kind === "check" || command.kind === "limit") {
220
- if (initState.active) {
221
- ctx.ui.notify(`Wait for /init to finish before changing goal ${command.kind}`, "error");
222
- return;
223
- }
224
- const maxTurns = command.kind === "limit" && command.value.kind === "count" ? command.value.count : undefined;
225
- const current = runtime.state;
226
- if (!current) {
227
- ctx.ui.notify("No goal is set", "info");
228
- return;
229
- }
230
- if (current.status === "complete") {
231
- ctx.ui.notify("The goal is complete. Set a new objective or use /goal edit.", "info");
232
- return;
233
- }
234
- let completionCheck: GoalCompletionCheck | undefined = current.completionCheck;
235
- if (command.kind === "check") {
236
- try {
237
- completionCheck = command.value.kind === "clear" ? undefined : resolveGoalCompletionCheck(ctx, command.value.name);
238
- } catch (error) {
239
- reportError(ctx, "Goal completion check could not be set", error);
240
- return;
241
- }
242
- }
243
- runtime.continuationHeld = true;
244
- try {
245
- await ctx.waitForIdle();
246
- } catch (error) {
247
- runtime.continuationHeld = false;
248
- reportError(ctx, "Goal could not wait for the active turn", error);
249
- scheduleGoalContinuation(pi, runtime, initState, ctx);
250
- return;
251
- }
252
- runtime.continuationHeld = false;
253
- const latest = runtime.state;
254
- if (!latest || latest.status === "complete") {
255
- ctx.ui.notify(latest?.status === "complete" ? "The goal completed before its controls changed." : "No goal is set", "info");
256
- return;
257
- }
258
- if (command.kind === "check" && command.value.kind === "named") {
259
- try {
260
- completionCheck = resolveGoalCompletionCheck(ctx, command.value.name);
261
- } catch (error) {
262
- reportError(ctx, "Goal completion check could not be set", error);
263
- scheduleGoalContinuation(pi, runtime, initState, ctx);
264
- return;
265
- }
266
- } else if (command.kind === "check") {
267
- completionCheck = undefined;
268
- } else {
269
- completionCheck = latest.completionCheck;
270
- }
271
- const nextLimit = command.kind === "limit" ? maxTurns : latest.maxTurns;
272
- let next = updateGoalControlsState(latest, { completionCheck, ...(nextLimit === undefined ? {} : { maxTurns: nextLimit }) }, Date.now());
273
- const exhausted = next.status === "active" && next.maxTurns !== undefined && next.turns >= next.maxTurns;
274
- if (exhausted) next = pauseGoalState(next, `Turn limit reached (${next.turns}/${next.maxTurns}).`, Date.now());
275
- try {
276
- persistGoalState(pi, runtime, command.kind, next);
277
- runtime.continuationScheduled = false;
278
- if (exhausted) {
279
- ctx.ui.notify(`Goal paused: turn limit reached (${next.turns}/${next.maxTurns})`, "warning");
280
- } else {
281
- const message = command.kind === "check"
282
- ? completionCheck ? `Goal completion check set to ${completionCheck.name}` : "Goal completion check cleared"
283
- : next.maxTurns === undefined ? "Goal turn limit cleared" : `Goal turn limit set to ${next.maxTurns}`;
284
- ctx.ui.notify(message, "info");
285
- scheduleGoalContinuation(pi, runtime, initState, ctx);
286
- }
287
- } catch (error) {
288
- reportError(ctx, `Goal ${command.kind} could not be changed`, error);
289
- scheduleGoalContinuation(pi, runtime, initState, ctx);
290
- }
291
- return;
292
- }
293
-
294
186
  if (command.kind === "clear") {
295
187
  if (!runtime.state) {
296
188
  ctx.ui.notify("No goal is set", "info");
@@ -401,17 +293,30 @@ export function registerGoalInterface(
401
293
  return;
402
294
  }
403
295
  if (runtime.state.status === "complete") {
404
- ctx.ui.notify("The goal is complete. Set a new objective or use /goal edit.", "info");
405
- return;
406
- }
407
- if (runtime.state.maxTurns !== undefined && runtime.state.turns >= runtime.state.maxTurns) {
408
- ctx.ui.notify(`Goal turn limit reached (${runtime.state.turns}/${runtime.state.maxTurns}). Raise or clear it before resuming.`, "warning");
296
+ ctx.ui.notify("The goal is complete. Set a new objective.", "info");
409
297
  return;
410
298
  }
411
299
  if (runtime.state.status === "active") {
412
300
  ctx.ui.notify("Goal is already active", "info");
413
301
  return;
414
302
  }
303
+ const currentMax = runtime.state.maxTurns;
304
+ if (currentMax !== undefined && runtime.state.turns >= currentMax) {
305
+ if (runtime.state.turns >= GOAL_MAX_TURNS) {
306
+ ctx.ui.notify(`Goal reached the lifetime limit (${runtime.state.turns}/${GOAL_MAX_TURNS}). Set a new objective.`, "warning");
307
+ return;
308
+ }
309
+ const renewed = Math.min(Math.max(currentMax, runtime.state.turns) + DEFAULT_GOAL_MAX_TURNS, GOAL_MAX_TURNS);
310
+ try {
311
+ const base = transitionGoalState(runtime.state, "active", undefined, { resetBlockedAudit: true }, Date.now());
312
+ persistGoalState(pi, runtime, "resume", { ...base, maxTurns: renewed });
313
+ runtime.continuationScheduled = false;
314
+ if (scheduleGoalContinuation(pi, runtime, initState, ctx)) ctx.ui.notify("Goal resumed", "info");
315
+ } catch (error) {
316
+ reportError(ctx, "Goal could not be resumed", error);
317
+ }
318
+ return;
319
+ }
415
320
  try {
416
321
  transitionGoal(pi, runtime, "resume", "active", undefined, { resetBlockedAudit: true });
417
322
  runtime.continuationScheduled = false;
@@ -422,80 +327,12 @@ export function registerGoalInterface(
422
327
  return;
423
328
  }
424
329
 
425
- if (command.kind === "edit") {
426
- if (initState.active) {
427
- ctx.ui.notify("Wait for /init to finish before editing a goal", "error");
428
- return;
429
- }
430
- if (!runtime.state) {
431
- ctx.ui.notify("No goal is set", "info");
432
- return;
433
- }
434
- if (ctx.mode !== "tui") {
435
- ctx.ui.notify("/goal edit requires interactive TUI mode", "error");
436
- return;
437
- }
438
- runtime.continuationHeld = true;
439
- let waitError: unknown;
440
- try {
441
- await ctx.waitForIdle();
442
- } catch (error) {
443
- waitError = error;
444
- } finally {
445
- runtime.continuationHeld = false;
446
- }
447
- if (waitError) {
448
- reportError(ctx, "Goal could not wait for the active turn", waitError);
449
- scheduleGoalContinuation(pi, runtime, initState, ctx);
450
- return;
451
- }
452
- const edited = await ctx.ui.editor("Edit long-running goal", runtime.state.objective);
453
- if (edited === undefined) {
454
- scheduleGoalContinuation(pi, runtime, initState, ctx);
455
- return;
456
- }
457
- const objective = validateGoalObjective(edited);
458
- if (!objective) {
459
- ctx.ui.notify(edited.trim() ? "A goal objective may not exceed 4,000 characters" : "A goal objective may not be empty", "error");
460
- scheduleGoalContinuation(pi, runtime, initState, ctx);
461
- return;
462
- }
463
- let verification: Awaited<ReturnType<typeof inferGoalVerification>>;
464
- try {
465
- verification = await inferGoalVerification(objective);
466
- } catch (error) {
467
- reportError(ctx, "Goal verification could not be inferred", error);
468
- scheduleGoalContinuation(pi, runtime, initState, ctx);
469
- return;
470
- }
471
- const next = editGoalState(runtime.state, objective, verification, Date.now());
472
- try {
473
- persistGoalState(pi, runtime, "edit", next);
474
- runtime.continuationScheduled = false;
475
- if (scheduleGoalContinuation(pi, runtime, initState, ctx)) ctx.ui.notify("Goal updated and active", "info");
476
- } catch (error) {
477
- if (runtime.state?.status === "active") {
478
- pauseGoalAfterFailure(
479
- pi,
480
- runtime,
481
- ctx,
482
- `Goal could not be edited: ${error instanceof Error ? error.message : String(error)}`,
483
- "Automatic continuation is stopped. Retry /goal edit after session storage recovers.",
484
- );
485
- } else {
486
- reportError(ctx, "Goal could not be edited", error);
487
- }
488
- }
489
- return;
490
- }
491
-
492
330
  if (initState.active) {
493
331
  ctx.ui.notify("Wait for /init to finish before starting a goal", "error");
494
332
  return;
495
333
  }
496
334
  switch (command.kind) {
497
335
  case "objective":
498
- case "start":
499
336
  break;
500
337
  default: {
501
338
  const unhandled: never = command;
@@ -503,17 +340,6 @@ export function registerGoalInterface(
503
340
  }
504
341
  }
505
342
  const objective = command.objective;
506
- const controlledStart = command.kind === "start" ? command : undefined;
507
-
508
- let completionCheck: GoalCompletionCheck | undefined;
509
- if (controlledStart?.completionCheckName) {
510
- try {
511
- completionCheck = resolveGoalCompletionCheck(ctx, controlledStart.completionCheckName);
512
- } catch (error) {
513
- reportError(ctx, "Goal completion check could not be resolved", error);
514
- return;
515
- }
516
- }
517
343
 
518
344
  const unfinished = runtime.state && runtime.state.status !== "complete";
519
345
  if (unfinished) {
@@ -539,14 +365,21 @@ export function registerGoalInterface(
539
365
  scheduleGoalContinuation(pi, runtime, initState, ctx);
540
366
  return;
541
367
  }
368
+ let verification: Awaited<ReturnType<typeof inferGoalVerification>>;
542
369
  try {
543
- if (controlledStart?.completionCheckName) {
544
- completionCheck = resolveGoalCompletionCheck(ctx, controlledStart.completionCheckName);
370
+ verification = await inferGoalVerification(objective, ctx.cwd);
371
+ } catch (error) {
372
+ if (!unfinished) {
373
+ reportError(ctx, "Goal could not be started", error);
374
+ } else {
375
+ reportError(ctx, "Goal could not be replaced", error);
376
+ scheduleGoalContinuation(pi, runtime, initState, ctx);
545
377
  }
546
- const verification = await inferGoalVerification(objective);
378
+ return;
379
+ }
380
+ try {
547
381
  const state = createNewGoalState(objective, sumGoalTokens(ctx), verification, Date.now(), {
548
- ...(completionCheck === undefined ? {} : { completionCheck }),
549
- maxTurns: controlledStart?.maxTurns ?? DEFAULT_GOAL_MAX_TURNS,
382
+ maxTurns: DEFAULT_GOAL_MAX_TURNS,
550
383
  });
551
384
  persistGoalState(pi, runtime, unfinished ? "replace" : "set", state);
552
385
  if (scheduleGoalContinuation(pi, runtime, initState, ctx)) {
@@ -573,23 +406,11 @@ export function registerGoalInterface(
573
406
  description: "Set a non-command objective or view the current goal",
574
407
  getArgumentCompletions: (prefix) => {
575
408
  const normalized = prefix.trimStart().toLowerCase();
576
- const actions = normalized.startsWith("start ")
577
- ? [
578
- { value: "start --check ", description: "Bind a named completion check" },
579
- { value: "start --turns ", description: "Set a goal turn limit" },
580
- { value: "start -- ", description: "Start a goal with strict syntax" },
581
- ]
582
- : [
583
- { value: "clear", description: "Remove the current goal" },
584
- { value: "edit", description: "Edit and reactivate the current goal" },
585
- { value: "pause", description: "Stop automatic continuation" },
586
- { value: "resume", description: "Resume automatic continuation" },
587
- { value: "start", description: "Start with optional controls" },
588
- { value: "check", description: "Set or clear a completion check" },
589
- { value: "checks", description: "List completion checks" },
590
- { value: "limit", description: "Set or clear a turn limit" },
591
- { value: "history", description: "Show goal history" },
592
- ];
409
+ const actions = [
410
+ { value: "clear", description: "Remove the current goal" },
411
+ { value: "pause", description: "Stop automatic continuation" },
412
+ { value: "resume", description: "Resume automatic continuation" },
413
+ ];
593
414
  return actions
594
415
  .filter((action) => action.value.startsWith(normalized))
595
416
  .map((action) => ({ ...action, label: action.value.trimEnd() }));
@@ -9,7 +9,7 @@ export const GOAL_ENTRY_TYPE = "killeros-goal";
9
9
  const GOAL_CONTINUATION_TYPE = "killeros-goal-continuation";
10
10
  export const GOAL_UPDATE_TOOL = "killeros_goal_update";
11
11
 
12
- export type GoalEntryEvent = "set" | "replace" | "edit" | "check" | "limit" | "turn" | "pause" | "resume" | "blocked" | "complete" | "error" | "clear" | "checkpoint" | "blocker-audit";
12
+ export type GoalEntryEvent = "set" | "replace" | "limit" | "turn" | "pause" | "resume" | "blocked" | "complete" | "error" | "clear" | "checkpoint" | "blocker-audit";
13
13
  export interface GoalEntryData {
14
14
  version: 1;
15
15
  event: GoalEntryEvent;
@@ -125,14 +125,12 @@ export function pauseGoalAtTurnLimit(
125
125
  pi: ExtensionAPI,
126
126
  runtime: GoalRuntime,
127
127
  ctx: ExtensionContext,
128
- notify = true,
129
128
  ): boolean {
130
129
  const state = runtime.state;
131
130
  if (state?.status !== "active" || state.maxTurns === undefined || state.turns < state.maxTurns) return false;
132
131
  const result = `Turn limit reached (${state.turns}/${state.maxTurns}).`;
133
132
  try {
134
133
  transitionGoal(pi, runtime, "limit", "paused", result);
135
- if (notify) ctx.ui.notify(`Goal paused: turn limit reached (${state.turns}/${state.maxTurns})`, "warning");
136
134
  } catch (error) {
137
135
  pauseGoalAfterFailure(pi, runtime, ctx, `turn limit pause could not be saved: ${error instanceof Error ? error.message : String(error)}`);
138
136
  }
@@ -2,12 +2,11 @@ import { createHash } from "node:crypto";
2
2
  import type { Stats } from "node:fs";
3
3
  import { lstat, open, type FileHandle } from "node:fs/promises";
4
4
  import path from "node:path";
5
- import type { GoalBlockerAudit, GoalCompletionCheck, GoalFileBaseline, GoalFileVerification, GoalState, GoalStateCommon, GoalStatus } from "./runtime.ts";
5
+ import type { GoalBlockerAudit, GoalFileBaseline, GoalFileVerification, GoalState, GoalStateCommon, GoalStatus } from "./runtime.ts";
6
6
 
7
7
  export const DEFAULT_GOAL_MAX_TURNS = 20;
8
8
  export const GOAL_OBJECTIVE_LIMIT = 4_000;
9
9
  export const GOAL_MAX_TURNS = 10_000;
10
- export const GOAL_CHECK_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
11
10
  export const GOAL_VERSION = 1;
12
11
  const FILE_HASH_CHUNK_SIZE = 64 * 1024;
13
12
  export const FILE_HASH_LIMIT = 64 * 1024 * 1024;
@@ -64,6 +63,14 @@ function isAbsoluteFilePath(value: string): boolean {
64
63
  return path.isAbsolute(value) || path.win32.isAbsolute(value);
65
64
  }
66
65
 
66
+ function stripUnquotedPathPunctuation(value: string): string {
67
+ const pathWithoutMarks = value.replace(/[.!?]+$/u, "");
68
+ const trailingClosers = pathWithoutMarks.match(/\)+$/u)?.[0].length ?? 0;
69
+ const unmatchedClosers = Math.max(0, pathWithoutMarks.split(")").length - pathWithoutMarks.split("(").length);
70
+ const punctuationLength = Math.min(trailingClosers, unmatchedClosers);
71
+ return pathWithoutMarks.slice(0, punctuationLength ? -punctuationLength : undefined);
72
+ }
73
+
67
74
  function isGoalFileVerification(value: unknown): value is GoalFileVerification {
68
75
  return isUnknownRecord(value)
69
76
  && value.kind === "file"
@@ -73,15 +80,6 @@ function isGoalFileVerification(value: unknown): value is GoalFileVerification {
73
80
  && isGoalFileBaseline(value.baseline);
74
81
  }
75
82
 
76
- function isGoalCompletionCheck(value: unknown): value is GoalCompletionCheck {
77
- return isUnknownRecord(value)
78
- && value.kind === "named-command"
79
- && typeof value.name === "string"
80
- && GOAL_CHECK_NAME_PATTERN.test(value.name)
81
- && typeof value.configHash === "string"
82
- && /^[a-f0-9]{64}$/u.test(value.configHash);
83
- }
84
-
85
83
  function isMaxTurns(value: unknown): value is number {
86
84
  return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= GOAL_MAX_TURNS;
87
85
  }
@@ -118,7 +116,6 @@ export function parseGoalState(value: unknown): GoalState | undefined {
118
116
  resumeAfterManualCompaction,
119
117
  blockerAudit,
120
118
  verification,
121
- completionCheck,
122
119
  maxTurns,
123
120
  } = value;
124
121
  if (version !== GOAL_VERSION
@@ -134,7 +131,6 @@ export function parseGoalState(value: unknown): GoalState | undefined {
134
131
  || !safeNonNegativeInteger(baselineTokens)
135
132
  || result !== undefined && typeof result !== "string"
136
133
  || verification !== undefined && !isGoalFileVerification(verification)
137
- || completionCheck !== undefined && !isGoalCompletionCheck(completionCheck)
138
134
  || maxTurns !== undefined && !isMaxTurns(maxTurns)
139
135
  || resumeAfterManualCompaction !== undefined && resumeAfterManualCompaction !== true
140
136
  || blockerAudit !== undefined && !isGoalBlockerAudit(blockerAudit, turns, status)) {
@@ -152,7 +148,6 @@ export function parseGoalState(value: unknown): GoalState | undefined {
152
148
  blockedAuditStartTurn: blockedAuditStartTurn ?? 0,
153
149
  baselineTokens,
154
150
  ...(verification === undefined ? {} : { verification }),
155
- ...(completionCheck === undefined ? {} : { completionCheck }),
156
151
  ...(maxTurns === undefined ? {} : { maxTurns }),
157
152
  };
158
153
  switch (status) {
@@ -238,13 +233,26 @@ export async function captureGoalFileBaseline(
238
233
  }
239
234
  }
240
235
 
241
- /** Captures one explicit absolute output path so goal completion can verify its creation or modification. */
242
- export async function inferGoalVerification(objective: string): Promise<GoalFileVerification | undefined> {
236
+ /** Captures one explicit output path so goal completion can verify its creation or modification. */
237
+ export async function inferGoalVerification(objective: string, cwd: string): Promise<GoalFileVerification | undefined> {
238
+ const candidates: string[] = [];
243
239
  const destination = /\b(?:create|write|save|generate)\b[^\r\n]{0,160}?\b(?:file|document|markdown|report|spreadsheet|presentation|image)\b\s+(?:to|at|as|destination(?:\s+is)?|output(?:\s+(?:to|at))?)\b\s*(?:`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)'|([A-Za-z]:\\[^\s,;]+|\/[^\s,;]+))/giu;
244
- const paths = [...objective.matchAll(destination)]
245
- .map((match) => (match[1] ?? match[2] ?? match[3] ?? match[4] ?? "").trim())
246
- .filter(isAbsoluteFilePath);
247
- const unique = [...new Set(paths)];
240
+ for (const match of objective.matchAll(destination)) {
241
+ const quoted = match[1] ?? match[2] ?? match[3];
242
+ candidates.push(quoted !== undefined ? quoted.trim() : stripUnquotedPathPunctuation((match[4] ?? "").trim()));
243
+ }
244
+ const direct = /\b(?:update|edit|fix|refactor|migrate)\s+(`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)')/giu;
245
+ for (const match of objective.matchAll(direct)) {
246
+ const quoted = match[2] ?? match[3] ?? match[4];
247
+ if (quoted !== undefined) candidates.push(quoted.trim());
248
+ }
249
+ const resolved: string[] = [];
250
+ for (const raw of candidates) {
251
+ if (!raw || /^(?:https?|file):\/\//iu.test(raw) || /[\\\/]$/u.test(raw)) continue;
252
+ const absolute = path.isAbsolute(raw) || path.win32.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
253
+ if (isAbsoluteFilePath(absolute)) resolved.push(absolute);
254
+ }
255
+ const unique = [...new Set(resolved)];
248
256
  const filePath = unique.length === 1 ? unique[0] : undefined;
249
257
  return filePath ? { kind: "file", path: filePath, baseline: await captureGoalFileBaseline(filePath) } : undefined;
250
258
  }
@@ -302,7 +310,6 @@ export function commonGoalState(state: GoalState): GoalStateCommon {
302
310
  blockedAuditStartTurn: state.blockedAuditStartTurn,
303
311
  baselineTokens: state.baselineTokens,
304
312
  ...(state.verification === undefined ? {} : { verification: state.verification }),
305
- ...(state.completionCheck === undefined ? {} : { completionCheck: state.completionCheck }),
306
313
  ...(state.maxTurns === undefined ? {} : { maxTurns: state.maxTurns }),
307
314
  };
308
315
  }
@@ -319,7 +326,7 @@ export function createNewGoalState(
319
326
  baselineTokens: number,
320
327
  verification: GoalFileVerification | undefined,
321
328
  now: number,
322
- controls: { completionCheck?: GoalCompletionCheck; maxTurns?: number } = {},
329
+ controls: { maxTurns?: number } = {},
323
330
  ): GoalState {
324
331
  return {
325
332
  version: GOAL_VERSION,
@@ -334,71 +341,10 @@ export function createNewGoalState(
334
341
  blockedAuditStartTurn: 0,
335
342
  baselineTokens,
336
343
  ...(verification === undefined ? {} : { verification }),
337
- ...(controls.completionCheck === undefined ? {} : { completionCheck: controls.completionCheck }),
338
344
  ...(controls.maxTurns === undefined ? {} : { maxTurns: controls.maxTurns }),
339
345
  };
340
346
  }
341
347
 
342
- export function editGoalState(
343
- state: GoalState,
344
- objective: string,
345
- verification: GoalFileVerification | undefined,
346
- now: number,
347
- ): GoalState {
348
- const current = stopGoalClock(state, now);
349
- const { verification: _previousVerification, ...common } = current;
350
- return {
351
- ...common,
352
- revision: current.revision + 1,
353
- objective,
354
- status: "active",
355
- updatedAt: now,
356
- activeStartedAt: now,
357
- blockedAuditStartTurn: current.turns,
358
- ...(verification === undefined ? {} : { verification }),
359
- };
360
- }
361
-
362
- export function updateGoalControlsState(
363
- state: Exclude<GoalState, { status: "complete" }>,
364
- controls: { completionCheck?: GoalCompletionCheck; maxTurns?: number },
365
- now: number,
366
- ): GoalState {
367
- const stopped = stopGoalClock(state, now);
368
- const { completionCheck: _completionCheck, maxTurns: _maxTurns, ...common } = stopped;
369
- const nextCommon: GoalStateCommon = {
370
- ...common,
371
- revision: common.revision + 1,
372
- updatedAt: now,
373
- ...(controls.completionCheck === undefined ? {} : { completionCheck: controls.completionCheck }),
374
- ...(controls.maxTurns === undefined ? {} : { maxTurns: controls.maxTurns }),
375
- };
376
- if (state.status === "active") {
377
- return {
378
- ...nextCommon,
379
- status: "active",
380
- activeStartedAt: now,
381
- ...(state.result === undefined ? {} : { result: state.result }),
382
- ...(state.blockerAudit === undefined ? {} : { blockerAudit: state.blockerAudit }),
383
- };
384
- }
385
- if (state.status === "paused") {
386
- return {
387
- ...nextCommon,
388
- status: "paused",
389
- ...(state.result === undefined ? {} : { result: state.result }),
390
- ...(state.blockerAudit === undefined ? {} : { blockerAudit: state.blockerAudit }),
391
- ...(state.resumeAfterManualCompaction === undefined ? {} : { resumeAfterManualCompaction: true }),
392
- };
393
- }
394
- return {
395
- ...nextCommon,
396
- status: "blocked",
397
- result: state.result,
398
- ...(state.blockerAudit === undefined ? {} : { blockerAudit: state.blockerAudit }),
399
- };
400
- }
401
-
402
348
  export function beginGoalTurnState(
403
349
  current: Extract<GoalState, { status: "active" }>,
404
350
  now: number,
package/killeros/hooks.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { createHash } from "node:crypto";
2
1
  import {
3
2
  spawn,
4
3
  type SpawnOptionsWithStdioTuple,
@@ -9,8 +8,6 @@ import path from "node:path";
9
8
  import { StringDecoder } from "node:string_decoder";
10
9
  import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
11
10
  import { errorMessage, reportError } from "./errors.ts";
12
- import { GOAL_CHECK_NAME_PATTERN } from "./goal-state.ts";
13
- import type { GoalCompletionCheck } from "./runtime.ts";
14
11
  import { safeTerminalText } from "./safe-terminal-text.ts";
15
12
 
16
13
  type KillerosHookEvent = "tool_call" | "tool_result" | "agent_settled";
@@ -21,14 +18,8 @@ interface KillerosHook {
21
18
  timeoutMs?: number;
22
19
  }
23
20
 
24
- interface KillerosGoalCheck {
25
- command: string;
26
- timeoutMs?: number;
27
- }
28
-
29
21
  interface KillerosHookConfig {
30
22
  hooks?: Partial<Record<KillerosHookEvent, KillerosHook[]>>;
31
- goalChecks?: Record<string, KillerosGoalCheck>;
32
23
  }
33
24
 
34
25
  interface HookExecutionResult {
@@ -70,8 +61,6 @@ const HOOK_OUTPUT_LIMIT = 16 * 1024;
70
61
  const HOOK_PAYLOAD_LIMIT = 8_000;
71
62
  const HOOK_TIMEOUT_DEFAULT_MS = 30_000;
72
63
  const HOOK_TIMEOUT_MAX_MS = 300_000;
73
- const GOAL_CHECK_LIMIT = 32;
74
- const GOAL_CHECK_COMMAND_LIMIT = 8_000;
75
64
 
76
65
  // Reads executable project configuration through a bounded, project-local file descriptor.
77
66
  function readHookConfig(configPath: string, projectRoot: string): string {
@@ -122,12 +111,11 @@ function isUnknownRecord(value: unknown): value is Record<string, unknown> {
122
111
  return typeof value === "object" && value !== null && !Array.isArray(value);
123
112
  }
124
113
 
125
- function loadKillerosConfig(ctx: ExtensionContext, strictGoalChecks = false): KillerosHookConfig {
114
+ function loadKillerosConfig(ctx: ExtensionContext): KillerosHookConfig {
126
115
  const configPath = path.join(ctx.cwd, CONFIG_DIR_NAME, "killeros-hooks.json");
127
116
  if (!existsSync(configPath)) return {};
128
117
  const displayPath = safeTerminalText(configPath).replaceAll("\n", "");
129
118
  if (!ctx.isProjectTrusted()) {
130
- if (strictGoalChecks) throw new Error("Goal completion checks require a trusted project");
131
119
  ctx.ui.notify(`Ignored untrusted project hooks in ${displayPath}`, "warning");
132
120
  return {};
133
121
  }
@@ -181,34 +169,8 @@ function loadKillerosConfig(ctx: ExtensionContext, strictGoalChecks = false): Ki
181
169
  hooks[event] = accepted;
182
170
  }
183
171
 
184
- let goalChecks: Record<string, KillerosGoalCheck> | undefined;
185
- try {
186
- const candidates = parsed.goalChecks;
187
- if (candidates !== undefined) {
188
- if (!isUnknownRecord(candidates)) throw new Error("goalChecks must contain a JSON object");
189
- const entries = Object.entries(candidates);
190
- if (entries.length > GOAL_CHECK_LIMIT) throw new Error(`goalChecks may contain at most ${GOAL_CHECK_LIMIT} checks`);
191
- goalChecks = {};
192
- for (const [name, candidate] of entries) {
193
- if (!GOAL_CHECK_NAME_PATTERN.test(name)) throw new Error(`Invalid goal check name: ${JSON.stringify(name)}`);
194
- if (!isUnknownRecord(candidate)) throw new Error(`Goal check ${name} must contain a JSON object`);
195
- const { command, timeoutMs } = candidate;
196
- if (typeof command !== "string" || command.trim().length < 1 || command.trim().length > GOAL_CHECK_COMMAND_LIMIT) {
197
- throw new Error(`Goal check ${name} command must contain 1 to ${GOAL_CHECK_COMMAND_LIMIT} characters`);
198
- }
199
- if (timeoutMs !== undefined && (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > HOOK_TIMEOUT_MAX_MS)) {
200
- throw new Error(`Goal check ${name} timeoutMs must be an integer from 1 to ${HOOK_TIMEOUT_MAX_MS}`);
201
- }
202
- goalChecks[name] = { command: command.trim(), ...(timeoutMs === undefined ? {} : { timeoutMs }) };
203
- }
204
- }
205
- } catch (error) {
206
- if (strictGoalChecks) throw error;
207
- reportError(ctx, `Invalid ${CONFIG_DIR_NAME}/killeros-hooks.json goalChecks`, error);
208
- }
209
- return { hooks, ...(goalChecks === undefined ? {} : { goalChecks }) };
172
+ return { hooks };
210
173
  } catch (error) {
211
- if (strictGoalChecks) throw error;
212
174
  reportError(ctx, `Invalid ${CONFIG_DIR_NAME}/killeros-hooks.json`, error);
213
175
  return {};
214
176
  }
@@ -427,48 +389,6 @@ function hookFailureMessage(result: HookExecutionResult): string {
427
389
  return safeTerminalText(`Hook failed${result.timedOut ? " (timed out)" : ""}${result.exitUnconfirmed ? " (process exit unconfirmed)" : ""}\n${detail}`);
428
390
  }
429
391
 
430
- function goalCheckHash(check: KillerosGoalCheck): string {
431
- return createHash("sha256")
432
- .update(JSON.stringify({ command: check.command, timeoutMs: check.timeoutMs ?? HOOK_TIMEOUT_DEFAULT_MS }))
433
- .digest("hex");
434
- }
435
-
436
- /** Lists validated completion-check names without exposing their definitions. */
437
- export function listGoalCompletionChecks(ctx: ExtensionContext): readonly string[] {
438
- if (!ctx.isProjectTrusted()) throw new Error("Goal completion checks require a trusted project");
439
- return Object.keys(loadKillerosConfig(ctx, true).goalChecks ?? {}).sort();
440
- }
441
-
442
- export function resolveGoalCompletionCheck(ctx: ExtensionContext, name: string): GoalCompletionCheck {
443
- if (!GOAL_CHECK_NAME_PATTERN.test(name)) throw new Error("Invalid goal completion check name");
444
- if (!ctx.isProjectTrusted()) throw new Error("Goal completion checks require a trusted project");
445
- const check = loadKillerosConfig(ctx, true).goalChecks?.[name];
446
- if (!check) throw new Error(`Unknown goal completion check: ${safeTerminalText(name)}`);
447
- return { kind: "named-command", name, configHash: goalCheckHash(check) };
448
- }
449
-
450
- export async function runGoalCompletionCheck(
451
- ctx: ExtensionContext,
452
- bound: GoalCompletionCheck,
453
- signal?: AbortSignal,
454
- ): Promise<void> {
455
- if (!ctx.isProjectTrusted()) throw new Error("Goal completion checks require a trusted project");
456
- const check = loadKillerosConfig(ctx, true).goalChecks?.[bound.name];
457
- if (!check) throw new Error(`Unknown goal completion check: ${safeTerminalText(bound.name)}`);
458
- if (goalCheckHash(check) !== bound.configHash) {
459
- throw new Error(`Goal completion check ${safeTerminalText(bound.name)} changed; run /goal check ${safeTerminalText(bound.name)} to approve it`);
460
- }
461
- const result = await executeHook({
462
- command: check.command,
463
- cwd: ctx.cwd,
464
- environment: { KILLEROS_EVENT: "goal_check", KILLEROS_GOAL_CHECK: bound.name },
465
- timeoutMs: check.timeoutMs,
466
- signal,
467
- });
468
- if (result.cancelled) throw new Error(`Goal completion check ${safeTerminalText(bound.name)} was cancelled`);
469
- if (result.code !== 0) throw new Error(hookFailureMessage(result).replace(/^Hook failed/u, `Goal completion check ${safeTerminalText(bound.name)} failed`));
470
- }
471
-
472
392
  export function registerLifecycleHooks(pi: ExtensionAPI): void {
473
393
  let config: KillerosHookConfig = {};
474
394
  pi.on("session_start", (_event, ctx) => { config = loadKillerosConfig(ctx); });
@@ -39,12 +39,6 @@ export interface GoalFileVerification {
39
39
  baseline: GoalFileBaseline;
40
40
  }
41
41
 
42
- export interface GoalCompletionCheck {
43
- kind: "named-command";
44
- name: string;
45
- configHash: string;
46
- }
47
-
48
42
  export interface GoalStateCommon {
49
43
  version: 1;
50
44
  revision: number;
@@ -56,7 +50,6 @@ export interface GoalStateCommon {
56
50
  blockedAuditStartTurn: number;
57
51
  baselineTokens: number;
58
52
  verification?: GoalFileVerification;
59
- completionCheck?: GoalCompletionCheck;
60
53
  maxTurns?: number;
61
54
  }
62
55
 
@@ -348,7 +348,7 @@ export function registerWorkedFor(
348
348
  const settled = active;
349
349
  active = undefined;
350
350
  const changes = await (await settled.collection).finish();
351
- if (changes.state === "unavailable" && changes.reason !== "not-git" && !collectionNoticeShown) {
351
+ if (changes.state === "unavailable" && changes.reason !== "not-git" && changes.reason !== "timeout" && !collectionNoticeShown) {
352
352
  collectionNoticeShown = true;
353
353
  ctx.ui.notify(`Change receipt unavailable: ${changes.reason}`, "warning");
354
354
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "2.1.22",
3
+ "version": "2.1.23",
4
4
  "description": "TUI, goals, and workflow automation for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -1,71 +0,0 @@
1
- import { formatTime, formatTokens } from "./display.ts";
2
- import { parseGoalState } from "./goal-state.ts";
3
- import type { GoalState } from "./runtime.ts";
4
- import { safeTerminalText } from "./safe-terminal-text.ts";
5
-
6
- const GOAL_ENTRY_TYPE = "killeros-goal";
7
- const VALID_GOAL_EVENTS: ReadonlySet<string> = new Set([
8
- "set", "replace", "edit", "check", "limit", "turn", "pause", "resume",
9
- "blocker-audit", "blocked", "complete", "error", "clear", "checkpoint",
10
- ]);
11
- function isUnknownRecord(value: unknown): value is Record<string, unknown> {
12
- return typeof value === "object" && value !== null && !Array.isArray(value);
13
- }
14
-
15
- function tokenUsage(entry: Record<string, unknown>): number | undefined {
16
- if (entry.type === "message" && isUnknownRecord(entry.message)
17
- && (entry.message.role === "assistant" || entry.message.role === "toolResult")
18
- && isUnknownRecord(entry.message.usage)
19
- && typeof entry.message.usage.totalTokens === "number"
20
- && Number.isFinite(entry.message.usage.totalTokens)
21
- && entry.message.usage.totalTokens >= 0) {
22
- return entry.message.usage.totalTokens;
23
- }
24
- if ((entry.type === "compaction" || entry.type === "branch_summary")
25
- && isUnknownRecord(entry.usage)
26
- && typeof entry.usage.totalTokens === "number"
27
- && Number.isFinite(entry.usage.totalTokens)
28
- && entry.usage.totalTokens >= 0) {
29
- return entry.usage.totalTokens;
30
- }
31
- return undefined;
32
- }
33
-
34
- function preview(value: string): string {
35
- const safe = safeTerminalText(value).replaceAll("\n", " ").trim();
36
- const characters = [...safe];
37
- return characters.length <= 160 ? safe : `${characters.slice(0, 159).join("")}…`;
38
- }
39
-
40
- function eventDetail(event: string, state: GoalState): string {
41
- if (event === "check") return state.completionCheck ? `check ${state.completionCheck.name}` : "check cleared";
42
- if (event === "limit") return state.maxTurns === undefined ? "limit cleared" : `limit ${state.maxTurns}`;
43
- if (event === "blocker-audit" && state.blockerAudit) {
44
- return `Blocker ${state.blockerAudit.streak}/3: ${state.blockerAudit.evidence ?? state.blockerAudit.key}`;
45
- }
46
- return state.result || state.objective;
47
- }
48
-
49
- /** Projects branch entries into the latest bounded goal-history rows. */
50
- export function formatGoalHistory(entries: readonly unknown[], count: number): string | undefined {
51
- const lines: string[] = [];
52
- let tokens = 0;
53
- let previousState: GoalState | undefined;
54
- for (const value of entries) {
55
- if (!isUnknownRecord(value)) continue;
56
- const usage = tokenUsage(value);
57
- if (usage !== undefined) {
58
- tokens += usage;
59
- continue;
60
- }
61
- if (value.type !== "custom" || value.customType !== GOAL_ENTRY_TYPE || !isUnknownRecord(value.data)) continue;
62
- const event = value.data.event;
63
- if (typeof event !== "string" || !VALID_GOAL_EVENTS.has(event)) continue;
64
- const state = value.data.state === null ? previousState : parseGoalState(value.data.state);
65
- if (!state) continue;
66
- if (value.data.state !== null) previousState = state;
67
- if (event === "turn" || event === "checkpoint") continue;
68
- lines.push(`+${formatTime(Math.max(0, state.updatedAt - state.createdAt))} ${event} turn ${state.turns} ${formatTokens(Math.max(0, tokens - state.baselineTokens))} tokens ${preview(eventDetail(event, state))}`);
69
- }
70
- return lines.length ? lines.slice(-count).join("\n") : undefined;
71
- }