killeros 2.0.7 → 2.0.8

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,21 @@ All notable changes to KillerOS are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.0.8] - 2026-08-15
8
+
9
+ ### Added
10
+
11
+ - Added a reusable, opt-in pre-turn gate for explicitly activated decision-gated workflows. It opens the shared structured question UI before skill expansion, keeps the selected policy active, blocks unknown and disallowed tools, and clears safely across lifecycle boundaries.
12
+ - Added a disposable decision-gated workflow fixture and focused coverage for activation ordering, pending safety, policy allowlists, lifecycle cleanup, adapter reuse, and Pi 0.84.2 compatibility.
13
+
14
+ ### Removed
15
+
16
+ - Removed KillerOS's always-on concise response policy and provider-specific concise defaults; Pi now owns response-style guidance.
17
+
18
+ ### Changed
19
+
20
+ - Raised the locked direct Pi development packages to 0.84.2 while keeping the peer dependency floor at 0.84.1.
21
+
7
22
  ## [2.0.7] - 2026-08-13
8
23
 
9
24
  ### Changed
package/Killeros.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { registerRequestActivity } from "./killeros/activity.ts";
3
3
  import { registerAliases, registerSlashAutocomplete } from "./killeros/commands.ts";
4
- import { registerConcisePrompt } from "./killeros/concise.ts";
5
4
  import { registerFooter } from "./killeros/footer.ts";
6
5
  import { registerGoal, registerGoalSettlement } from "./killeros/goals.ts";
7
6
  import { registerLifecycleHooks } from "./killeros/hooks.ts";
@@ -11,31 +10,47 @@ import {
11
10
  type CompletionNotificationDependencies,
12
11
  } from "./killeros/notifications.ts";
13
12
  import { registerPersonalInstructions } from "./killeros/personal-instructions.ts";
14
- import { registerQuestionTool } from "./killeros/question.ts";
13
+ import { registerQuestionTool, type QuestionRunner } from "./killeros/question.ts";
14
+ import { createDecisionGatedWorkflowAdapter } from "./killeros/decision-gated-workflow.ts";
15
15
  import { createGoalRuntime, createInitRuntime } from "./killeros/runtime.ts";
16
16
  import { registerShellUi } from "./killeros/shell-ui.ts";
17
17
  import { registerVariants } from "./killeros/variants.ts";
18
18
  import { registerWorkedFor } from "./killeros/worked-for.ts";
19
+ import { registerWorkflowGate, type WorkflowAdapter } from "./killeros/workflow-gate.ts";
19
20
 
20
- export { CONCISE_SYSTEM_PROMPT, isConciseEnabled, isConcisedEnabled } from "./killeros/concise.ts";
21
21
  export { contextPercentRemaining, formatCost, formatContextProgress } from "./killeros/footer.ts";
22
22
  export { executeHook } from "./killeros/hooks.ts";
23
23
  export { INIT_WORKFLOW_PROMPT } from "./killeros/init.ts";
24
24
  export { buildInitEvidence, listInitEvidence, readInitEvidence } from "./killeros/init-evidence.ts";
25
25
  export { captureInitTargetBaseline, installInitAgentsFile, validateGeneratedGuidance, writeInitAgentsFile } from "./killeros/init-target.ts";
26
+ export { createDecisionGatedWorkflowAdapter } from "./killeros/decision-gated-workflow.ts";
27
+ export { explicitSkillActivation, registerWorkflowGate } from "./killeros/workflow-gate.ts";
28
+ export type {
29
+ WorkflowAdapter,
30
+ WorkflowGateController,
31
+ WorkflowGateState,
32
+ WorkflowPolicy,
33
+ WorkflowTerminalReason,
34
+ WorkflowToolAuthorization,
35
+ } from "./killeros/workflow-gate.ts";
26
36
 
27
37
  export interface KillerosOptions {
28
38
  completionNotifications?: CompletionNotificationDependencies;
39
+ decisionGatedWorkflows?: readonly WorkflowAdapter[];
29
40
  }
30
41
 
31
42
  export default function Killeros(pi: ExtensionAPI, options: KillerosOptions = {}): void {
32
43
  const initRuntime = createInitRuntime();
33
44
  const goalRuntime = createGoalRuntime();
34
45
  registerShellUi(pi);
35
- registerConcisePrompt(pi);
36
46
  registerGoal(pi, goalRuntime, initRuntime);
37
47
  registerPersonalInstructions(pi, initRuntime);
38
- registerQuestionTool(pi);
48
+ const questionRunner: QuestionRunner = registerQuestionTool(pi);
49
+ registerWorkflowGate(
50
+ pi,
51
+ questionRunner,
52
+ options.decisionGatedWorkflows ?? [createDecisionGatedWorkflowAdapter()],
53
+ );
39
54
  registerAliases(pi);
40
55
  registerSlashAutocomplete(pi);
41
56
  registerFooter(pi, goalRuntime);
package/README.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # KillerOS
2
2
 
3
- A production-hardened Pi extension that combines a custom TUI, repository initialization, long-running goals, reasoning controls, interactive questions, command aliases, and concise-response guidance.
3
+ A production-hardened Pi extension that combines a custom TUI, repository initialization, long-running goals, reasoning controls, interactive questions, and command aliases.
4
4
 
5
5
  ## Requirements
6
6
 
7
7
  - Node.js `22.19.0` or later
8
- - Pi `0.84.1` or later
8
+ - Pi `0.84.2` or later
9
9
  - Interactive TUI mode for the custom header, editor, footer, `question` tool, and `/init`
10
10
 
11
11
  The extension is strict TypeScript. Pi provides the runtime modules.
@@ -31,7 +31,7 @@ pi install git:github.com/KyrosHendrix/pi-KillerOS
31
31
  Pin an install to a release:
32
32
 
33
33
  ```bash
34
- pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.7
34
+ pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.8
35
35
  ```
36
36
 
37
37
  Add `-l` to either command for a project-only install. Restart Pi after installing.
@@ -48,11 +48,11 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
48
48
  - Optional completion sounds after successful or failed settled requests, excluding manual aborts
49
49
  - `/variants` selector and direct reasoning-level arguments
50
50
  - Codex-style `/goal` with an interactive status/action panel, durable objectives, immediate pause and clear cancellation, automatic continuation, explicit completion, and durable blocker audits
51
- - Automatic `/init` guideline synthesis with a frozen safe evidence map, protected existing policy, and the four packaged behavioral sections adapted from `writing-great-guidelines`
51
+ - Automatic `/init` guideline synthesis with a frozen safe evidence map, protected existing policy, and the four packaged behavioral sections adapted from `writing-great-guidelines`
52
+ - Opt-in decision-gated workflows that ask a structured policy question before explicit skill expansion, preserve the selected allowlist, and fail closed across tool and session boundaries
52
53
  - `question` tool with single-select and opt-in bounded multi-select, height-bounded option windows, configured Pi keybindings, live option/input progress, proposal previews, custom answers, history, cancellation, and compact expandable transcript rendering
53
- - Mid-prompt slash completion with current Pi `0.84.1` commands, extensions, prompts, and skills; paths, URLs, and invalid commands remain plain text
54
+ - Mid-prompt slash completion with current Pi `0.84.2` commands, extensions, prompts, and skills; paths, URLs, and invalid commands remain plain text
54
55
  - Goal-aware `/clear` that confirms, aborts active work, waits for settlement, and starts a new session, plus `/exit` for graceful shutdown
55
- - Concise system-prompt guidance and supported native concise defaults that preserve explicit provider settings
56
56
 
57
57
  ## Commands
58
58
 
@@ -77,9 +77,13 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
77
77
 
78
78
  `/init` freezes a safe project-file map and exposes only dedicated read and list operations while it generates root `AGENTS.md`. Git-ignored files, known secret paths, private-key formats, other guidance, dependencies, links, non-regular files, and files outside that map are unavailable. Existing root `AGENTS.md` is separate protected policy: compatible rules are preserved, a real policy conflict leaves it unchanged with a reason, and any concurrent target change aborts installation without replacing the newer file.
79
79
 
80
- The generated file uses the four behavioral sections adapted from `writing-great-guidelines`; no external skill installation is required. `/init` asks no setup questions, starts no second model process, writes no other file, and reloads Pi resources only after a successful write.
81
-
82
- ### Interactive questions
80
+ The generated file uses the four behavioral sections adapted from `writing-great-guidelines`; no external skill installation is required. `/init` asks no setup questions, starts no second model process, writes no other file, and reloads Pi resources only after a successful write.
81
+
82
+ ### Decision-gated workflows
83
+
84
+ Explicit `/skill:decision-gated-workflow` activation opens the shared question UI before Pi expands the skill. `Normal` allows only interview and read-only tools; `With docs` additionally permits agreed glossary, context-map, and ADR paths. The selected policy remains active until the workflow is explicitly finished or cancelled, and lifecycle changes clear it safely. Extensions can supply additional adapters through `KillerosOptions.decisionGatedWorkflows`.
85
+
86
+ ### Interactive questions
83
87
 
84
88
  Single-select remains the default. Explicit `minSelections: 1` and `maxSelections: 1` are equivalent to omitting both bounds; other single-select bounds are rejected. An agent opts into multi-select with `mode: "multiple"` and may set `minSelections` and `maxSelections`; the custom answer counts as one selection.
85
89
 
@@ -108,8 +112,8 @@ Lifecycle hooks are loaded from `.pi/killeros-hooks.json` at session start. Supp
108
112
  | Mode | Behavior |
109
113
  |---|---|
110
114
  | TUI | All features are available, including the completion sound and tab-title indicator |
111
- | RPC | Goal set/view/pause/resume/clear and concise prompt guidance work; TUI components, `/goal edit`, `/init`, completion sounds, and the title indicator are disabled |
112
- | Print/JSON | Concise prompt guidance works; interactive questions, `/goal`, and `/init` fail explicitly; completion sounds and the title indicator are disabled |
115
+ | RPC | Goal set/view/pause/resume/clear work; TUI components, `/goal edit`, `/init`, completion sounds, and the title indicator are disabled |
116
+ | Print/JSON | Interactive questions, `/goal`, and `/init` fail explicitly; completion sounds and the title indicator are disabled |
113
117
 
114
118
  ## Validation
115
119
 
@@ -0,0 +1,76 @@
1
+ import path from "node:path";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import type { QuestionDetails } from "./question.ts";
4
+ import type { WorkflowAdapter, WorkflowPolicy, WorkflowToolAuthorization } from "./workflow-gate.ts";
5
+
6
+ const READ_ONLY_TOOLS = ["read", "grep", "find", "ls", "question"] as const;
7
+
8
+ const DOCUMENTATION_PATHS: readonly RegExp[] = [
9
+ /^(?:docs\/)?(?:glossary|context-map)(?:\.md|\/|$)/u,
10
+ /^docs\/adr\/[^/]+\.md$/u,
11
+ ];
12
+
13
+ function relativePath(input: Readonly<Record<string, unknown>>, ctx: ExtensionContext): string | undefined {
14
+ if (typeof input.path !== "string" || input.path.trim().length === 0) return;
15
+ const absolute = path.resolve(ctx.cwd, input.path);
16
+ const relative = path.relative(ctx.cwd, absolute);
17
+ if (!relative || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return;
18
+ return relative.replaceAll(path.sep, "/").toLocaleLowerCase();
19
+ }
20
+
21
+ function authorizeDocumentationTool(
22
+ toolName: string,
23
+ input: Readonly<Record<string, unknown>>,
24
+ ctx: ExtensionContext,
25
+ ): WorkflowToolAuthorization {
26
+ if (toolName !== "edit" && toolName !== "write") return true;
27
+ const target = relativePath(input, ctx);
28
+ if (target && DOCUMENTATION_PATHS.some((pattern) => pattern.test(target))) return true;
29
+ return "With docs policy permits writes only to the agreed glossary, context-map, and ADR paths";
30
+ }
31
+
32
+ const NORMAL_POLICY: WorkflowPolicy = {
33
+ id: "normal",
34
+ allowedTools: READ_ONLY_TOOLS,
35
+ };
36
+
37
+ const WITH_DOCS_POLICY: WorkflowPolicy = {
38
+ id: "with-docs",
39
+ allowedTools: [...READ_ONLY_TOOLS, "edit", "write"],
40
+ authorizeTool: authorizeDocumentationTool,
41
+ };
42
+
43
+ const POLICIES = [NORMAL_POLICY, WITH_DOCS_POLICY] as const;
44
+
45
+ function selectedAnswer(details: QuestionDetails): string | undefined {
46
+ if (!("answer" in details) || details.answer === null) return;
47
+ return details.answer;
48
+ }
49
+
50
+ export function createDecisionGatedWorkflowAdapter(): WorkflowAdapter {
51
+ return {
52
+ id: "decision-gated-workflow",
53
+ activation: "decision-gated-workflow",
54
+ question: {
55
+ question: "Choose the policy for this workflow before the model starts",
56
+ options: [
57
+ {
58
+ label: "Normal",
59
+ description: "Interview and read-only work; implementation files stay protected.",
60
+ },
61
+ {
62
+ label: "With docs",
63
+ description: "Allow only agreed glossary, context-map, or ADR documentation writes.",
64
+ },
65
+ ],
66
+ },
67
+ policies: POLICIES,
68
+ selectPolicy(details) {
69
+ switch (selectedAnswer(details)) {
70
+ case "Normal": return NORMAL_POLICY;
71
+ case "With docs": return WITH_DOCS_POLICY;
72
+ default: return undefined;
73
+ }
74
+ },
75
+ };
76
+ }
@@ -1,5 +1,10 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
- import { type ExtensionAPI, type ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ type ExtensionAPI,
4
+ type ExtensionContext,
5
+ type ThemeColor,
6
+ type ToolDefinition,
7
+ } from "@earendil-works/pi-coding-agent";
3
8
  import {
4
9
  decodeKittyPrintable,
5
10
  Editor,
@@ -40,7 +45,7 @@ const QuestionParams = Type.Object({
40
45
  })),
41
46
  });
42
47
 
43
- type QuestionParamsValue = Static<typeof QuestionParams>;
48
+ export type QuestionParamsValue = Static<typeof QuestionParams>;
44
49
 
45
50
  type NormalizedQuestionSelection =
46
51
  | { mode: "single"; minSelections: 1; maxSelections: 1 }
@@ -95,7 +100,11 @@ interface MultipleQuestionDetails {
95
100
  cancelled?: boolean;
96
101
  }
97
102
 
98
- type QuestionDetails = SingleQuestionDetails | MultipleQuestionDetails;
103
+ export type QuestionDetails = SingleQuestionDetails | MultipleQuestionDetails;
104
+
105
+ export interface QuestionRunner {
106
+ ask(params: QuestionParamsValue, signal: AbortSignal | undefined, ctx: ExtensionContext): Promise<QuestionDetails>;
107
+ }
99
108
 
100
109
  type QuestionSelection =
101
110
  | { kind: "selected"; answer: string; originalIndex: number }
@@ -210,7 +219,7 @@ class MultipleResultText {
210
219
  invalidate(): void {}
211
220
  }
212
221
 
213
- export function registerQuestionTool(pi: ExtensionAPI): void {
222
+ export function registerQuestionTool(pi: ExtensionAPI): QuestionRunner {
214
223
  const customInputHistory: string[] = [];
215
224
  let customInputHistoryBytes = 0;
216
225
  const clearCustomInputHistory = (): void => {
@@ -242,7 +251,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
242
251
  pi.on("session_tree", clearCustomInputHistory);
243
252
  pi.on("session_shutdown", clearCustomInputHistory);
244
253
 
245
- pi.registerTool<typeof QuestionParams, QuestionDetails>({
254
+ const questionTool: ToolDefinition<typeof QuestionParams, QuestionDetails> = {
246
255
  name: "question",
247
256
  label: "Question",
248
257
  description: `Ask one interactive multiple-choice question. Provide 1-9 concise options. Single-select is the default; opt into bounded multi-select with mode "multiple". The user can filter options or type a custom answer. Filter queries are limited to ${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()} characters and ${FILTER_QUERY_MAX_BYTES.toLocaleString()} bytes.`,
@@ -752,5 +761,14 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
752
761
  }
753
762
  return new BoundedText(`${theme.fg("success", "✓ ")}${theme.fg("accent", answer)}`);
754
763
  },
755
- });
764
+ };
765
+ pi.registerTool(questionTool);
766
+
767
+ return {
768
+ async ask(params, signal, ctx): Promise<QuestionDetails> {
769
+ const result = await questionTool.execute("killeros-workflow-gate", params, signal, undefined, ctx);
770
+ if (!result.details) throw new Error("Question did not return a structured result");
771
+ return result.details;
772
+ },
773
+ };
756
774
  }
@@ -0,0 +1,347 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ ToolCallEvent,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import type { QuestionDetails, QuestionParamsValue, QuestionRunner } from "./question.ts";
7
+
8
+ export type WorkflowToolAuthorization = true | false | string;
9
+
10
+ export interface WorkflowPolicy {
11
+ id: string;
12
+ allowedTools: readonly string[];
13
+ authorizeTool?: (
14
+ toolName: string,
15
+ input: Readonly<Record<string, unknown>>,
16
+ ctx: ExtensionContext,
17
+ ) => WorkflowToolAuthorization;
18
+ }
19
+
20
+ export interface WorkflowAdapter {
21
+ id: string;
22
+ activation: string;
23
+ question: QuestionParamsValue;
24
+ policies: readonly WorkflowPolicy[];
25
+ selectPolicy: (details: QuestionDetails) => WorkflowPolicy | undefined;
26
+ onActivated?: (policy: WorkflowPolicy, details: QuestionDetails) => void | Promise<void>;
27
+ onFinish?: () => void | Promise<void>;
28
+ onCancel?: (reason: string) => void | Promise<void>;
29
+ onFailure?: (error: unknown) => void | Promise<void>;
30
+ }
31
+
32
+ export type WorkflowGateState =
33
+ | { kind: "inactive" }
34
+ | { kind: "pending_decision"; adapterId: string; activation: string }
35
+ | { kind: "active"; adapterId: string; activation: string; policyId: string }
36
+ | { kind: "terminal_cleanup"; adapterId: string; activation: string; reason: WorkflowTerminalReason };
37
+
38
+ export interface WorkflowGateController {
39
+ getState(): WorkflowGateState;
40
+ finish(): Promise<boolean>;
41
+ cancel(reason?: string): Promise<boolean>;
42
+ }
43
+
44
+ export type WorkflowTerminalReason = "finish" | "cancel" | "fail" | "session-reset";
45
+
46
+ type InternalState =
47
+ | { kind: "inactive" }
48
+ | {
49
+ kind: "pending_decision";
50
+ adapter: WorkflowAdapter;
51
+ abortController: AbortController;
52
+ token: symbol;
53
+ }
54
+ | { kind: "active"; adapter: WorkflowAdapter; policy: WorkflowPolicy; token: symbol }
55
+ | { kind: "terminal_cleanup"; adapter: WorkflowAdapter; reason: WorkflowTerminalReason; token: symbol };
56
+
57
+ function explicitSkillActivation(text: string): string | undefined {
58
+ if (!text.startsWith("/skill:")) return;
59
+ const spaceIndex = text.indexOf(" ");
60
+ const activation = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);
61
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(activation) ? activation : undefined;
62
+ }
63
+
64
+ function isCancelled(details: QuestionDetails): boolean {
65
+ if (details.cancelled) return true;
66
+ return "answer" in details && details.answer === null;
67
+ }
68
+
69
+ function errorMessage(error: unknown): string {
70
+ return error instanceof Error ? error.message : String(error);
71
+ }
72
+
73
+ function publicState(state: InternalState): WorkflowGateState {
74
+ if (state.kind === "inactive") return state;
75
+ if (state.kind === "pending_decision") {
76
+ return {
77
+ kind: state.kind,
78
+ adapterId: state.adapter.id,
79
+ activation: state.adapter.activation,
80
+ };
81
+ }
82
+ if (state.kind === "terminal_cleanup") {
83
+ return {
84
+ kind: state.kind,
85
+ adapterId: state.adapter.id,
86
+ activation: state.adapter.activation,
87
+ reason: state.reason,
88
+ };
89
+ }
90
+ return {
91
+ kind: state.kind,
92
+ adapterId: state.adapter.id,
93
+ activation: state.adapter.activation,
94
+ policyId: state.policy.id,
95
+ };
96
+ }
97
+
98
+ function notify(ctx: ExtensionContext, message: string, type: "info" | "warning" | "error" = "error"): void {
99
+ ctx.ui.notify(message, type);
100
+ }
101
+
102
+ function isKnownPolicy(adapter: WorkflowAdapter, policy: WorkflowPolicy): boolean {
103
+ return adapter.policies.includes(policy);
104
+ }
105
+
106
+ async function invokeCleanup(callback: (() => void | Promise<void>) | undefined): Promise<void> {
107
+ try {
108
+ await callback?.();
109
+ } catch {
110
+ // Cleanup callbacks are best effort; the gate must still reach a terminal state.
111
+ }
112
+ }
113
+
114
+ async function invokeCancel(
115
+ callback: ((reason: string) => void | Promise<void>) | undefined,
116
+ reason: string,
117
+ ): Promise<void> {
118
+ try {
119
+ await callback?.(reason);
120
+ } catch {
121
+ // Cleanup callbacks are best effort; the gate must still reach a terminal state.
122
+ }
123
+ }
124
+
125
+ async function invokeFailure(
126
+ callback: ((error: unknown) => void | Promise<void>) | undefined,
127
+ error: unknown,
128
+ ): Promise<void> {
129
+ try {
130
+ await callback?.(error);
131
+ } catch {
132
+ // Cleanup callbacks are best effort; the gate must still reach a terminal state.
133
+ }
134
+ }
135
+
136
+ export function registerWorkflowGate(
137
+ pi: ExtensionAPI,
138
+ questionRunner: QuestionRunner,
139
+ adapters: readonly WorkflowAdapter[],
140
+ ): WorkflowGateController {
141
+ const adaptersByActivation = new Map<string, WorkflowAdapter>();
142
+ for (const adapter of adapters) {
143
+ if (!adapter.id.trim()) throw new Error("Decision-gated workflow adapters require an id");
144
+ if (!/^[-A-Za-z0-9._]+$/u.test(adapter.activation)) {
145
+ throw new Error(`Invalid decision-gated workflow activation: ${adapter.activation}`);
146
+ }
147
+ if (adaptersByActivation.has(adapter.activation)) {
148
+ throw new Error(`Duplicate decision-gated workflow activation: ${adapter.activation}`);
149
+ }
150
+ if (adapter.policies.length === 0) throw new Error(`Workflow adapter ${adapter.id} has no policies`);
151
+ adaptersByActivation.set(adapter.activation, adapter);
152
+ }
153
+
154
+ let state: InternalState = { kind: "inactive" };
155
+
156
+ const transitionToCleanup = (
157
+ current: Exclude<InternalState, { kind: "inactive" } | { kind: "terminal_cleanup" }>,
158
+ reason: WorkflowTerminalReason,
159
+ ): symbol => {
160
+ state = {
161
+ kind: "terminal_cleanup",
162
+ adapter: current.adapter,
163
+ reason,
164
+ token: current.token,
165
+ };
166
+ if (current.kind === "pending_decision") current.abortController.abort();
167
+ return current.token;
168
+ };
169
+
170
+ const finishCleanup = (token: symbol): void => {
171
+ if (state.kind === "terminal_cleanup" && state.token === token) state = { kind: "inactive" };
172
+ };
173
+
174
+ const finish = async (): Promise<boolean> => {
175
+ if (state.kind !== "active") return false;
176
+ const active = state;
177
+ const token = transitionToCleanup(active, "finish");
178
+ await invokeCleanup(active.adapter.onFinish);
179
+ finishCleanup(token);
180
+ return true;
181
+ };
182
+
183
+ const cancel = async (reason = "Workflow cancelled"): Promise<boolean> => {
184
+ if (state.kind === "inactive" || state.kind === "terminal_cleanup") return false;
185
+ const current = state;
186
+ const token = transitionToCleanup(current, "cancel");
187
+ await invokeCancel(current.adapter.onCancel, reason);
188
+ finishCleanup(token);
189
+ return true;
190
+ };
191
+
192
+ const fail = async (
193
+ adapter: WorkflowAdapter,
194
+ token: symbol,
195
+ error: unknown,
196
+ ctx: ExtensionContext,
197
+ ): Promise<void> => {
198
+ if (state.kind === "inactive" || state.kind === "terminal_cleanup") return;
199
+ if (state.adapter !== adapter || state.token !== token) return;
200
+ const cleanupToken = transitionToCleanup(state, "fail");
201
+ await invokeFailure(adapter.onFailure, error);
202
+ finishCleanup(cleanupToken);
203
+ notify(ctx, `Decision-gated workflow was not activated: ${errorMessage(error)}`);
204
+ };
205
+
206
+ pi.on("input", async (event, ctx) => {
207
+ const activation = explicitSkillActivation(event.text);
208
+ if (!activation) return;
209
+
210
+ if (state.kind === "pending_decision" || state.kind === "terminal_cleanup") {
211
+ notify(ctx, "A decision-gated workflow is waiting for its structured question; skill routing is blocked", "warning");
212
+ return { action: "handled" };
213
+ }
214
+
215
+ const adapter = adaptersByActivation.get(activation);
216
+ if (!adapter) return;
217
+
218
+ if (state.kind === "active") {
219
+ notify(ctx, `Cannot start /skill:${activation} while a decision-gated workflow is active`, "warning");
220
+ return { action: "handled" };
221
+ }
222
+ if (ctx.mode !== "tui" || !ctx.hasUI) {
223
+ notify(ctx, `The /skill:${activation} workflow requires interactive TUI mode`);
224
+ return { action: "handled" };
225
+ }
226
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) {
227
+ notify(ctx, `The /skill:${activation} workflow can start only when Pi is idle`, "warning");
228
+ return { action: "handled" };
229
+ }
230
+
231
+ const abortController = new AbortController();
232
+ const token = Symbol();
233
+ state = { kind: "pending_decision", adapter, abortController, token };
234
+ let details: QuestionDetails;
235
+ try {
236
+ details = await questionRunner.ask(adapter.question, abortController.signal, ctx);
237
+ } catch (error) {
238
+ if (state.kind !== "pending_decision" || state.adapter !== adapter || state.token !== token) {
239
+ return { action: "handled" };
240
+ }
241
+ await fail(adapter, token, error, ctx);
242
+ return { action: "handled" };
243
+ }
244
+
245
+ if (state.kind !== "pending_decision" || state.adapter !== adapter || state.token !== token) {
246
+ return { action: "handled" };
247
+ }
248
+ if (isCancelled(details)) {
249
+ await cancel("Decision question cancelled");
250
+ notify(ctx, `The /skill:${activation} workflow was cancelled`, "warning");
251
+ return { action: "handled" };
252
+ }
253
+
254
+ let policy: WorkflowPolicy | undefined;
255
+ try {
256
+ policy = adapter.selectPolicy(details);
257
+ } catch (error) {
258
+ await fail(adapter, token, error, ctx);
259
+ return { action: "handled" };
260
+ }
261
+ if (!policy || !isKnownPolicy(adapter, policy)) {
262
+ await fail(adapter, token, new Error("The structured answer did not select a registered policy"), ctx);
263
+ return { action: "handled" };
264
+ }
265
+
266
+ try {
267
+ await adapter.onActivated?.(policy, details);
268
+ } catch (error) {
269
+ if (state.kind !== "pending_decision" || state.adapter !== adapter || state.token !== token) {
270
+ return { action: "handled" };
271
+ }
272
+ await fail(adapter, token, error, ctx);
273
+ return { action: "handled" };
274
+ }
275
+ if (state.kind !== "pending_decision" || state.adapter !== adapter || state.token !== token) {
276
+ return { action: "handled" };
277
+ }
278
+ state = { kind: "active", adapter, policy, token };
279
+ return { action: "continue" };
280
+ });
281
+
282
+ pi.on("tool_call", (event: ToolCallEvent, ctx) => {
283
+ if (state.kind === "inactive") return;
284
+ if (state.kind === "pending_decision" || state.kind === "terminal_cleanup") {
285
+ return {
286
+ block: true,
287
+ reason: "A decision-gated workflow is waiting for its structured question; no model tool calls are allowed yet",
288
+ };
289
+ }
290
+
291
+ const { policy } = state;
292
+ if (!policy.allowedTools.includes(event.toolName)) {
293
+ return {
294
+ block: true,
295
+ reason: `Tool ${event.toolName} is not allowed by decision-gated policy ${policy.id}`,
296
+ };
297
+ }
298
+ const authorization = policy.authorizeTool?.(event.toolName, event.input, ctx) ?? true;
299
+ if (authorization === true) return;
300
+ return {
301
+ block: true,
302
+ reason: typeof authorization === "string"
303
+ ? authorization
304
+ : `Tool ${event.toolName} is denied by decision-gated policy ${policy.id}`,
305
+ };
306
+ });
307
+
308
+ const resetForLifecycle = async (reason: string): Promise<void> => {
309
+ if (state.kind === "inactive" || state.kind === "terminal_cleanup") return;
310
+ const current = state;
311
+ const token = transitionToCleanup(current, "session-reset");
312
+ await invokeCancel(current.adapter.onCancel, reason);
313
+ finishCleanup(token);
314
+ };
315
+
316
+ pi.on("session_start", () => resetForLifecycle("Session lifecycle reset"));
317
+ pi.on("session_shutdown", () => resetForLifecycle("Session lifecycle reset"));
318
+ pi.on("session_tree", () => resetForLifecycle("Session tree reset"));
319
+ pi.on("session_before_switch", () => resetForLifecycle("Session switch reset"));
320
+ pi.on("session_before_fork", () => resetForLifecycle("Session fork reset"));
321
+ pi.on("session_before_tree", () => resetForLifecycle("Session tree reset"));
322
+
323
+ pi.on("agent_end", async (event, ctx) => {
324
+ if (state.kind !== "active") return;
325
+
326
+ let failureMessage: string | undefined;
327
+ for (let index = event.messages.length - 1; index >= 0; index -= 1) {
328
+ const message = event.messages[index];
329
+ if (message?.role === "assistant" && message.errorMessage) {
330
+ failureMessage = message.errorMessage;
331
+ break;
332
+ }
333
+ }
334
+ if (failureMessage === undefined) return;
335
+
336
+ const active = state;
337
+ await fail(active.adapter, active.token, new Error(failureMessage), ctx);
338
+ });
339
+
340
+ return {
341
+ getState: () => publicState(state),
342
+ finish,
343
+ cancel,
344
+ };
345
+ }
346
+
347
+ export { explicitSkillActivation };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "2.0.7",
3
+ "version": "2.0.8",
4
4
  "description": "TUI, goals, and workflow automation for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -48,9 +48,9 @@
48
48
  "typebox": ">=1.1.38 <2"
49
49
  },
50
50
  "devDependencies": {
51
- "@earendil-works/pi-ai": "0.84.1",
52
- "@earendil-works/pi-coding-agent": "0.84.1",
53
- "@earendil-works/pi-tui": "0.84.1",
51
+ "@earendil-works/pi-ai": "0.84.2",
52
+ "@earendil-works/pi-coding-agent": "0.84.2",
53
+ "@earendil-works/pi-tui": "0.84.2",
54
54
  "@types/node": "24.12.4",
55
55
  "typebox": "1.1.38",
56
56
  "typescript": "5.9.3"
@@ -1,69 +0,0 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
-
3
- export const CONCISE_SYSTEM_PROMPT = `
4
- # Action-oriented response guidance
5
-
6
- Make each response easy to start and easy to follow. Keep the state the user needs on screen instead of expecting them to remember missing context. Concise means low friction, not minimal detail: preserve everything required for safety, correctness, and action.
7
-
8
- ## Serve the immediate need
9
-
10
- The first line serves the user's immediate need: give the answer for an informational request, the next action for executable work, or the failure and recovery when blocked. Omit conversational preambles. If the agent can continue the work, continue instead of asking permission; if the task is complete, do not manufacture a next step.
11
-
12
- Finish the primary task before raising a separate concern. Mention an unrelated issue only when it materially affects safety, correctness, or the user's next decision. Ask one concrete question only when a material ambiguity remains after checking available code and context.
13
-
14
- ## Reduce action and memory friction
15
-
16
- Use structure by meaning: numbered steps for sequence, bullets for parallel facts or options, and short headings when they make a longer answer easier to scan or re-enter. Keep each step bounded. When a list grows beyond about five items, rank or group it instead of omitting required detail.
17
-
18
- Use a compact state anchor only when multi-step work spans turns or resumes after interruption. During tool-driven work, report meaningful phase changes, long-running verification, failures, and required decisions rather than narrating routine actions.
19
-
20
- ## Make outcomes and failures concrete
21
-
22
- When work completes, make the verified outcome prominent: state what now works and the evidence that proves it. Do not append a redundant recap. State failures matter-of-factly with the cause, impact, and recovery action. After three consecutive turns leave the same issue broken, stop speculative edits, name the likely invalid assumption from the observed evidence, and ask one diagnostic question.
23
-
24
- Never invent facts, completion claims, or timing. Give a concrete human execution estimate only when requested or supported by evidence, state its assumptions, and never predict the agent's own completion time. Preserve exact code, commands, paths, quoted text, warnings, and user-requested formats.
25
-
26
- ## Resolve conflicts deliberately
27
-
28
- Safety and harness constraints come first, followed by the user's explicit depth or format request, then correctness and completeness, then these concise defaults. Explain fully when asked, confirm before destructive actions, and retain necessary uncertainty rather than manufacturing confidence.
29
-
30
- Before sending, check that the first line serves the immediate need, filler and tangents are gone, exact artifacts and necessary uncertainty remain, and the ending is either the verified outcome or one action the user must take. Use a pragmatic tone: direct, plain, and focused on the task. Omit generic praise, recap sections, and closing pleasantries.
31
- `.trim();
32
-
33
- function isRecord(value: unknown): value is Record<string, unknown> {
34
- return typeof value === "object" && value !== null && !Array.isArray(value);
35
- }
36
-
37
- function applyConciseModelSettings(payload: unknown, api: unknown, modelId: unknown): unknown {
38
- if (!isRecord(payload) || !Array.isArray(payload.input)) return payload;
39
-
40
- const supportsSummary = api === "openai-codex-responses" || api === "openai-responses" || api === "azure-openai-responses";
41
- const supportsVerbosity = api === "openai-codex-responses"
42
- || api === "openai-responses" && typeof modelId === "string" && /^gpt-5(?:[.-]|$)/u.test(modelId);
43
- let updated = payload;
44
- const text = isRecord(payload.text) ? payload.text : undefined;
45
- if (supportsVerbosity && !Object.hasOwn(text ?? {}, "verbosity")) {
46
- updated = { ...updated, text: { ...(text ?? {}), verbosity: "low" } };
47
- }
48
- const reasoning = isRecord(payload.reasoning) ? payload.reasoning : undefined;
49
- if (supportsSummary && reasoning && !Object.hasOwn(reasoning, "summary")) {
50
- updated = { ...updated, reasoning: { ...reasoning, summary: "concise" } };
51
- }
52
- return updated;
53
- }
54
-
55
- export function isConciseEnabled(): boolean {
56
- return true;
57
- }
58
-
59
- /** @deprecated Use isConciseEnabled instead. */
60
- export function isConcisedEnabled(): boolean {
61
- return isConciseEnabled();
62
- }
63
-
64
- export function registerConcisePrompt(pi: ExtensionAPI): void {
65
- pi.on("before_agent_start", (event) => ({
66
- systemPrompt: `${event.systemPrompt}\n\n${CONCISE_SYSTEM_PROMPT}`,
67
- }));
68
- pi.on("before_provider_request", (event, ctx) => applyConciseModelSettings(event.payload, ctx.model?.api, ctx.model?.id));
69
- }