killeros 2.0.10 → 2.0.12

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,23 @@ All notable changes to KillerOS are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.0.12] - 2026-08-18
8
+
9
+ ### Fixed
10
+
11
+ - Resumed active goals exactly once after Pi reports automatic compaction complete.
12
+
13
+ ## [2.0.11] - 2026-08-17
14
+
15
+ ### Fixed
16
+
17
+ - Made automated npm publication and GitHub release recovery idempotent, aligned the documented and tested Pi floor, and made machine-identifier casing locale-independent.
18
+ - Extended strict type checking to release scripts and typed test suites, while correctly identifying JavaScript-only suites as `.js`.
19
+
20
+ ### Removed
21
+
22
+ - Removed the decision-gated workflow subsystem, its skill-specific policy, public extension interface, and YAML dependency. Skills now remain instruction-only.
23
+
7
24
  ## [2.0.10] - 2026-08-16
8
25
 
9
26
  ### Added
package/Killeros.ts CHANGED
@@ -15,33 +15,19 @@ import {
15
15
  type CompletionNotificationDependencies,
16
16
  } from "./killeros/notifications.ts";
17
17
  import { registerPersonalInstructions } from "./killeros/personal-instructions.ts";
18
- import { registerQuestionTool, type QuestionRunner } from "./killeros/question.ts";
19
- import { createDecisionGatedWorkflowAdapter } from "./killeros/decision-gated-workflow.ts";
18
+ import { registerQuestionTool } from "./killeros/question.ts";
20
19
  import { createGoalRuntime, createInitRuntime } from "./killeros/runtime.ts";
21
20
  import { registerShellUi } from "./killeros/shell-ui.ts";
22
21
  import { registerVariants } from "./killeros/variants.ts";
23
22
  import { registerWorkedFor } from "./killeros/worked-for.ts";
24
- import { registerWorkflowGate, type WorkflowAdapter } from "./killeros/workflow-gate.ts";
25
23
 
26
24
  export { contextPercentRemaining, formatCost, formatContextProgress } from "./killeros/footer.ts";
27
25
  export { executeHook } from "./killeros/hooks.ts";
28
26
  export { INIT_WORKFLOW_PROMPT } from "./killeros/init.ts";
29
27
  export { buildInitEvidence, listInitEvidence, readInitEvidence } from "./killeros/init-evidence.ts";
30
28
  export { captureInitTargetBaseline, installInitAgentsFile, validateGeneratedGuidance, writeInitAgentsFile } from "./killeros/init-target.ts";
31
- export { createDecisionGatedWorkflowAdapter } from "./killeros/decision-gated-workflow.ts";
32
- export { explicitSkillActivation, registerWorkflowGate } from "./killeros/workflow-gate.ts";
33
- export type {
34
- WorkflowAdapter,
35
- WorkflowGateController,
36
- WorkflowGateState,
37
- WorkflowPolicy,
38
- WorkflowTerminalReason,
39
- WorkflowToolAuthorization,
40
- } from "./killeros/workflow-gate.ts";
41
-
42
29
  export interface KillerosOptions {
43
30
  completionNotifications?: CompletionNotificationDependencies;
44
- decisionGatedWorkflows?: readonly WorkflowAdapter[];
45
31
  }
46
32
 
47
33
  export default function Killeros(pi: ExtensionAPI, options: KillerosOptions = {}): void {
@@ -51,12 +37,7 @@ export default function Killeros(pi: ExtensionAPI, options: KillerosOptions = {}
51
37
  registerShellUi(pi, commandResolver);
52
38
  registerGoal(pi, goalRuntime, initRuntime);
53
39
  registerPersonalInstructions(pi, initRuntime);
54
- const questionRunner: QuestionRunner = registerQuestionTool(pi);
55
- registerWorkflowGate(
56
- pi,
57
- questionRunner,
58
- options.decisionGatedWorkflows ?? [createDecisionGatedWorkflowAdapter()],
59
- );
40
+ registerQuestionTool(pi);
60
41
  registerAliases(pi);
61
42
  registerSlashAutocomplete(pi, commandResolver);
62
43
  registerFooter(pi, goalRuntime);
package/README.md CHANGED
@@ -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.10
34
+ pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.12
35
35
  ```
36
36
 
37
37
  Add `-l` to either command for a project-only install. Restart Pi after installing.
@@ -49,7 +49,6 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
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
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
53
52
  - `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
54
53
  - Mid-prompt slash completion with current Pi `0.84.2` commands, extensions, prompts, and skills; paths, URLs, and invalid commands remain plain text
55
54
  - Goal-aware `/clear` that confirms, aborts active work, waits for settlement, and starts a new session, plus `/exit` for graceful shutdown
@@ -79,10 +78,6 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
79
78
 
80
79
  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
80
 
82
- ### Decision-gated workflows
83
-
84
- Explicit `/skill:<name>` activation opens the shared question UI before Pi expands a registered skill. `WorkflowAdapter.activation` keeps the existing exact-match API, while `WorkflowAdapter.activations` lets one policy register multiple explicit skill names without duplicating the gate. `Normal` allows only interview and read-only tools; `With docs` additionally permits agreed glossary, context-map, and ADR paths. Unregistered names pass through unchanged, duplicate or ambiguous registrations fail at startup, and the selected policy remains active until the workflow is explicitly finished or cancelled. Extensions can supply additional adapters through `KillerosOptions.decisionGatedWorkflows`.
85
-
86
81
  ### Interactive questions
87
82
 
88
83
  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.
@@ -144,16 +139,9 @@ The package manifest lists Pi’s built-in modules as peer dependencies, so npm
144
139
 
145
140
  ## Publish
146
141
 
147
- To create a GitHub release, update the version in `package.json` and `package-lock.json`, add the matching `CHANGELOG.md` section, and push the release commit to `main`. After the full CI workflow passes, the release workflow creates the matching tag and GitHub release from that verified commit.
148
-
149
- Do not manually tag a normal release. If automation must recover a missing GitHub release, push the matching version tag; the same workflow validates the tag against the package and changelog before creating the release.
142
+ To release KillerOS, update the version in `package.json` and `package-lock.json`, add the matching `CHANGELOG.md` section, and push the release commit to `main`. After the full CI workflow passes, the release workflow publishes the package to npm through trusted publishing, then creates the matching tag and GitHub release from that verified commit. The [`pi-package`](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md) keyword makes the npm release visible in Pi’s package catalog.
150
143
 
151
- The [`pi-package`](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md) keyword makes a published npm release visible in Pi’s package catalog. GitHub release automation does not publish to npm. Publish there separately after validation:
152
-
153
- ```bash
154
- npm login
155
- npm publish
156
- ```
144
+ Do not manually tag a normal release. If automation must recover a missing GitHub release, push the matching version tag. The workflow validates the tag against the package and changelog, skips npm publication when that version already exists, and creates only the missing release.
157
145
 
158
146
  ## Security
159
147
 
@@ -30,7 +30,7 @@ export function formatActivityMessage(message: ActivityMessage, theme: Theme): s
30
30
  detail = "assembling the answer";
31
31
  break;
32
32
  case "tool":
33
- switch (message.toolName.trim().toLocaleLowerCase()) {
33
+ switch (message.toolName.trim().toLowerCase()) {
34
34
  case "read":
35
35
  case "grep":
36
36
  case "find":
@@ -166,8 +166,8 @@ export function createSlashCommandResolver(
166
166
 
167
167
  function scoreCommandMatch(name: string, prefix: string): number {
168
168
  if (!prefix) return 1;
169
- const normalizedName = name.toLocaleLowerCase();
170
- const normalizedPrefix = prefix.toLocaleLowerCase();
169
+ const normalizedName = name.toLowerCase();
170
+ const normalizedPrefix = prefix.toLowerCase();
171
171
  if (normalizedName.startsWith(normalizedPrefix)) return 100;
172
172
  if (normalizedName.split(/[:\-_]/).some((token) => token.startsWith(normalizedPrefix))) return 80;
173
173
  if (normalizedName.includes(normalizedPrefix)) return 50;
@@ -190,7 +190,7 @@ export function registerSlashAutocomplete(
190
190
  const prefixMatch = getSlashCommandPrefix(beforeCursor);
191
191
  if (!prefixMatch) return current.getSuggestions(lines, cursorLine, cursorCol, options);
192
192
 
193
- const prefix = prefixMatch.prefix.toLocaleLowerCase();
193
+ const prefix = prefixMatch.prefix.toLowerCase();
194
194
  const baseSuggestions = await current.getSuggestions(lines, cursorLine, cursorCol, options);
195
195
  resolver.updateFallbackCommands(baseSuggestions?.items ?? []);
196
196
  const commands = resolver.getCommandCatalog(baseSuggestions?.items ?? []);
@@ -6,8 +6,8 @@ export function formatCwd(cwd: string): string {
6
6
  if (!home) return cwd;
7
7
  const normalizedHome = home.replace(/[\\/]+$/, "");
8
8
  const normalizedCwd = cwd.replace(/[\\/]+$/, "");
9
- const comparedHome = process.platform === "win32" ? normalizedHome.toLocaleLowerCase() : normalizedHome;
10
- const comparedCwd = process.platform === "win32" ? normalizedCwd.toLocaleLowerCase() : normalizedCwd;
9
+ const comparedHome = process.platform === "win32" ? normalizedHome.toLowerCase() : normalizedHome;
10
+ const comparedCwd = process.platform === "win32" ? normalizedCwd.toLowerCase() : normalizedCwd;
11
11
  if (comparedCwd === comparedHome) return "~";
12
12
  const separator = normalizedCwd.slice(normalizedHome.length, normalizedHome.length + 1);
13
13
  return comparedCwd.startsWith(comparedHome) && (separator === "/" || separator === "\\")
@@ -75,12 +75,12 @@ const PROVIDER_WORDS: Readonly<Record<string, string>> = {
75
75
 
76
76
  function formatProviderName(provider: string): string {
77
77
  const normalized = provider.trim();
78
- const known = PROVIDER_LABELS[normalized.toLocaleLowerCase()];
78
+ const known = PROVIDER_LABELS[normalized.toLowerCase()];
79
79
  if (known) return known;
80
80
  return normalized
81
81
  .split(/[-_]+/u)
82
82
  .filter(Boolean)
83
- .map((word) => PROVIDER_WORDS[word.toLocaleLowerCase()] ?? `${word.charAt(0).toLocaleUpperCase()}${word.slice(1)}`)
83
+ .map((word) => PROVIDER_WORDS[word.toLowerCase()] ?? `${word.charAt(0).toUpperCase()}${word.slice(1)}`)
84
84
  .join(" ") || "Unknown provider";
85
85
  }
86
86
 
package/killeros/goals.ts CHANGED
@@ -291,7 +291,7 @@ async function stopGoalRun(runtime: GoalRuntime, ctx: ExtensionCommandContext, s
291
291
  }
292
292
 
293
293
  function goalStatusLabel(status: GoalStatus): string {
294
- return `${status.charAt(0).toLocaleUpperCase()}${status.slice(1)}`;
294
+ return `${status.charAt(0).toUpperCase()}${status.slice(1)}`;
295
295
  }
296
296
 
297
297
  function goalPanelActions(status: GoalStatus): Array<{ label: string; control: "pause" | "resume" | "edit" | "clear" }> {
@@ -309,7 +309,7 @@ function goalPanelActions(status: GoalStatus): Array<{ label: string; control: "
309
309
  function goalStatusSummary(state: GoalState, ctx: ExtensionContext): string {
310
310
  const usedTokens = Math.max(0, sumGoalTokens(ctx) - state.baselineTokens);
311
311
  const lines = [
312
- `Goal ${goalStatusLabel(state.status).toLocaleLowerCase()} · ${state.turns} turn${state.turns === 1 ? "" : "s"} · ${formatTime(goalElapsedMilliseconds(state))} · ${formatTokens(usedTokens)} tokens`,
312
+ `Goal ${goalStatusLabel(state.status).toLowerCase()} · ${state.turns} turn${state.turns === 1 ? "" : "s"} · ${formatTime(goalElapsedMilliseconds(state))} · ${formatTokens(usedTokens)} tokens`,
313
313
  state.objective,
314
314
  ];
315
315
  if (state.result) lines.push(state.result);
@@ -468,8 +468,6 @@ function completeAutomaticCompaction(
468
468
  runtime.automaticCompaction = undefined;
469
469
  return;
470
470
  }
471
- runtime.automaticCompaction = "completed";
472
- if (runtime.goalTurnInFlight || !ctx.isIdle()) return;
473
471
  runtime.automaticCompaction = undefined;
474
472
  setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
475
473
  }
@@ -708,7 +706,7 @@ export function registerGoal(
708
706
  return;
709
707
  }
710
708
  const input = args.trim();
711
- const control = input.toLocaleLowerCase();
709
+ const control = input.toLowerCase();
712
710
  const isControl = control === "clear" || control === "edit" || control === "pause" || control === "resume";
713
711
 
714
712
  if (!input) {
@@ -1008,7 +1006,7 @@ export function registerGoal(
1008
1006
  pi.registerCommand("goal", {
1009
1007
  description: "Set or view the goal for a long-running task",
1010
1008
  getArgumentCompletions: (prefix) => {
1011
- const normalized = prefix.trimStart().toLocaleLowerCase();
1009
+ const normalized = prefix.trimStart().toLowerCase();
1012
1010
  if (normalized.includes(" ")) return null;
1013
1011
  const actions = [
1014
1012
  { value: "clear", description: "Remove the current goal" },
@@ -1058,12 +1056,7 @@ export function registerGoalSettlement(
1058
1056
  const reason = runtime.lastError || "the agent turn was aborted";
1059
1057
  runtime.lastStopReason = undefined;
1060
1058
  runtime.lastError = undefined;
1061
- if (runtime.automaticCompaction !== undefined) {
1062
- if (runtime.automaticCompaction === "completed") {
1063
- completeAutomaticCompaction(pi, runtime, initState, ctx);
1064
- }
1065
- return;
1066
- }
1059
+ if (runtime.automaticCompaction !== undefined) return;
1067
1060
  pauseGoalForPossibleManualCompaction(pi, runtime, ctx, reason);
1068
1061
  return;
1069
1062
  }
@@ -1080,10 +1073,7 @@ export function registerGoalSettlement(
1080
1073
  });
1081
1074
 
1082
1075
  pi.on("session_compact", (event, ctx) => {
1083
- if (runtime.automaticCompaction !== undefined) {
1084
- completeAutomaticCompaction(pi, runtime, initState, ctx);
1085
- return;
1086
- }
1076
+ if (runtime.automaticCompaction !== undefined) return;
1087
1077
  if (event.reason !== "manual") return;
1088
1078
  recoverGoalAfterManualCompaction(pi, runtime, initState, ctx);
1089
1079
  });
@@ -35,11 +35,11 @@ export interface InitEvidenceBuildResult {
35
35
 
36
36
  function evidenceKey(relativePath: string): string {
37
37
  const normalized = relativePath.replaceAll("\\", "/");
38
- return process.platform === "win32" ? normalized.toLocaleLowerCase() : normalized;
38
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
39
39
  }
40
40
 
41
41
  function sensitiveEvidencePath(relativePath: string): boolean {
42
- const normalized = relativePath.replaceAll("\\", "/").toLocaleLowerCase();
42
+ const normalized = relativePath.replaceAll("\\", "/").toLowerCase();
43
43
  const name = path.posix.basename(normalized);
44
44
  return /^\.env(?:\.|$)/u.test(name)
45
45
  || [".npmrc", ".pypirc", ".netrc", "id_rsa", "id_ed25519", "credentials.json"].includes(name)
@@ -50,8 +50,8 @@ function sensitiveEvidencePath(relativePath: string): boolean {
50
50
  function excludedPath(relativePath: string): boolean {
51
51
  const segments = relativePath.replaceAll("\\", "/").split("/");
52
52
  return segments.some((segment, index) =>
53
- (index < segments.length - 1 && EXCLUDED_DIRS.has(segment.toLocaleLowerCase()))
54
- || EXCLUDED_GUIDANCE.has(segment.toLocaleLowerCase()))
53
+ (index < segments.length - 1 && EXCLUDED_DIRS.has(segment.toLowerCase()))
54
+ || EXCLUDED_GUIDANCE.has(segment.toLowerCase()))
55
55
  || sensitiveEvidencePath(relativePath);
56
56
  }
57
57
 
@@ -74,7 +74,7 @@ async function collectCandidates(projectRoot: string): Promise<string[]> {
74
74
  if (files.length >= PATH_LIMIT) break;
75
75
  const relativePath = path.posix.join(current.relativePath.replaceAll("\\", "/"), entry.name);
76
76
  if (entry.isDirectory()) {
77
- if (current.depth < DEPTH_LIMIT && !EXCLUDED_DIRS.has(entry.name.toLocaleLowerCase())) {
77
+ if (current.depth < DEPTH_LIMIT && !EXCLUDED_DIRS.has(entry.name.toLowerCase())) {
78
78
  queue.push({ relativePath, depth: current.depth + 1 });
79
79
  }
80
80
  } else if (entry.isFile() && !excludedPath(relativePath)) {
package/killeros/init.ts CHANGED
@@ -76,7 +76,7 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
76
76
  if (!initState.active || !initState.evidence || !initState.targetPath || !initState.projectRoot) {
77
77
  throw new Error("killeros_init_read is available only during /init");
78
78
  }
79
- const generatedTarget = initState.outcome.kind === "written" && requestedPath.replaceAll("\\", "/").toLocaleLowerCase() === "agents.md";
79
+ const generatedTarget = initState.outcome.kind === "written" && requestedPath.replaceAll("\\", "/").toLowerCase() === "agents.md";
80
80
  const text = generatedTarget
81
81
  ? await readGeneratedInitTarget(initState.projectRoot, initState.targetPath)
82
82
  : await readInitEvidence(initState.evidence, requestedPath);
@@ -45,7 +45,7 @@ const QuestionParams = Type.Object({
45
45
  })),
46
46
  });
47
47
 
48
- export type QuestionParamsValue = Static<typeof QuestionParams>;
48
+ type QuestionParamsValue = Static<typeof QuestionParams>;
49
49
 
50
50
  type NormalizedQuestionSelection =
51
51
  | { mode: "single"; minSelections: 1; maxSelections: 1 }
@@ -100,11 +100,7 @@ interface MultipleQuestionDetails {
100
100
  cancelled?: boolean;
101
101
  }
102
102
 
103
- export type QuestionDetails = SingleQuestionDetails | MultipleQuestionDetails;
104
-
105
- export interface QuestionRunner {
106
- ask(params: QuestionParamsValue, signal: AbortSignal | undefined, ctx: ExtensionContext): Promise<QuestionDetails>;
107
- }
103
+ type QuestionDetails = SingleQuestionDetails | MultipleQuestionDetails;
108
104
 
109
105
  type QuestionSelection =
110
106
  | { kind: "selected"; answer: string; originalIndex: number }
@@ -219,7 +215,7 @@ class MultipleResultText {
219
215
  invalidate(): void {}
220
216
  }
221
217
 
222
- export function registerQuestionTool(pi: ExtensionAPI): QuestionRunner {
218
+ export function registerQuestionTool(pi: ExtensionAPI): void {
223
219
  const customInputHistory: string[] = [];
224
220
  let customInputHistoryBytes = 0;
225
221
  const clearCustomInputHistory = (): void => {
@@ -303,7 +299,7 @@ export function registerQuestionTool(pi: ExtensionAPI): QuestionRunner {
303
299
  const keyText = keybindings.getKeys(keybinding)
304
300
  .join("/")
305
301
  .split("/")
306
- .map((key) => key.split("+").map((part) => process.platform === "darwin" && part.toLocaleLowerCase() === "alt" ? "option" : part).join("+"))
302
+ .map((key) => key.split("+").map((part) => process.platform === "darwin" && part.toLowerCase() === "alt" ? "option" : part).join("+"))
307
303
  .join("/");
308
304
  return theme.fg("dim", keyText) + theme.fg("muted", ` ${description}`);
309
305
  };
@@ -322,11 +318,11 @@ export function registerQuestionTool(pi: ExtensionAPI): QuestionRunner {
322
318
  customInputHistory.forEach((value) => editor.addToHistory(value));
323
319
 
324
320
  const filteredOptions = (): DisplayOption[] => {
325
- const query = filterQuery.trim().toLocaleLowerCase();
321
+ const query = filterQuery.trim().toLowerCase();
326
322
  return options.filter((option) => option.isOther
327
323
  || query.length === 0
328
- || option.label.toLocaleLowerCase().includes(query)
329
- || option.description?.toLocaleLowerCase().includes(query));
324
+ || option.label.toLowerCase().includes(query)
325
+ || option.description?.toLowerCase().includes(query));
330
326
  };
331
327
  const selectedCount = (): number => selectedOriginalIndices.size + (customAnswer === undefined ? 0 : 1);
332
328
  const orderedMultipleSelection = () => {
@@ -763,12 +759,4 @@ export function registerQuestionTool(pi: ExtensionAPI): QuestionRunner {
763
759
  },
764
760
  };
765
761
  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
- };
774
762
  }
@@ -57,7 +57,7 @@ export interface GoalRuntime {
57
57
  continuationHeld: boolean;
58
58
  goalTurnInFlight: boolean;
59
59
  agentEndObserved: boolean;
60
- automaticCompaction?: "pending" | "completed";
60
+ automaticCompaction?: "pending";
61
61
  persistenceRetryNeeded: boolean;
62
62
  lastStopReason?: string;
63
63
  lastError?: string;
@@ -46,7 +46,7 @@ function isThinkingLevel(value: string): value is ThinkingLevel {
46
46
  }
47
47
 
48
48
  function resolveThinkingLevel(input: string): ThinkingLevel | undefined {
49
- const normalized = input.trim().toLocaleLowerCase();
49
+ const normalized = input.trim().toLowerCase();
50
50
  return isThinkingLevel(normalized) ? normalized : LEVEL_ALIASES[normalized];
51
51
  }
52
52
 
@@ -144,7 +144,7 @@ export function registerVariants(pi: ExtensionAPI): void {
144
144
  const keyText = keybindings.getKeys(keybinding)
145
145
  .join("/")
146
146
  .split("/")
147
- .map((key) => key.split("+").map((part) => process.platform === "darwin" && part.toLocaleLowerCase() === "alt" ? "option" : part).join("+"))
147
+ .map((key) => key.split("+").map((part) => process.platform === "darwin" && part.toLowerCase() === "alt" ? "option" : part).join("+"))
148
148
  .join("/");
149
149
  return theme.fg("dim", keyText) + theme.fg("muted", ` ${description}`);
150
150
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "2.0.10",
3
+ "version": "2.0.12",
4
4
  "description": "TUI, goals, and workflow automation for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "scripts": {
33
33
  "check": "tsc --noEmit",
34
- "test": "node --test --experimental-strip-types test/*.test.ts"
34
+ "test": "node --test --experimental-strip-types test/*.test.*"
35
35
  },
36
36
  "pi": {
37
37
  "extensions": [
@@ -42,9 +42,9 @@
42
42
  ]
43
43
  },
44
44
  "peerDependencies": {
45
- "@earendil-works/pi-ai": ">=0.84.1",
46
- "@earendil-works/pi-coding-agent": ">=0.84.1",
47
- "@earendil-works/pi-tui": ">=0.84.1",
45
+ "@earendil-works/pi-ai": ">=0.84.2",
46
+ "@earendil-works/pi-coding-agent": ">=0.84.2",
47
+ "@earendil-works/pi-tui": ">=0.84.2",
48
48
  "typebox": ">=1.1.38 <2"
49
49
  },
50
50
  "devDependencies": {
@@ -1,76 +0,0 @@
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,365 +0,0 @@
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
- /** The existing single explicit skill activation API. */
23
- activation?: string;
24
- /** Additional explicit skill activations that share this adapter policy. */
25
- activations?: readonly string[];
26
- question: QuestionParamsValue;
27
- policies: readonly WorkflowPolicy[];
28
- selectPolicy: (details: QuestionDetails) => WorkflowPolicy | undefined;
29
- onActivated?: (policy: WorkflowPolicy, details: QuestionDetails) => void | Promise<void>;
30
- onFinish?: () => void | Promise<void>;
31
- onCancel?: (reason: string) => void | Promise<void>;
32
- onFailure?: (error: unknown) => void | Promise<void>;
33
- }
34
-
35
- export type WorkflowGateState =
36
- | { kind: "inactive" }
37
- | { kind: "pending_decision"; adapterId: string; activation: string }
38
- | { kind: "active"; adapterId: string; activation: string; policyId: string }
39
- | { kind: "terminal_cleanup"; adapterId: string; activation: string; reason: WorkflowTerminalReason };
40
-
41
- export interface WorkflowGateController {
42
- getState(): WorkflowGateState;
43
- finish(): Promise<boolean>;
44
- cancel(reason?: string): Promise<boolean>;
45
- }
46
-
47
- export type WorkflowTerminalReason = "finish" | "cancel" | "fail" | "session-reset";
48
-
49
- type InternalState =
50
- | { kind: "inactive" }
51
- | {
52
- kind: "pending_decision";
53
- adapter: WorkflowAdapter;
54
- activation: string;
55
- abortController: AbortController;
56
- token: symbol;
57
- }
58
- | { kind: "active"; adapter: WorkflowAdapter; activation: string; policy: WorkflowPolicy; token: symbol }
59
- | { kind: "terminal_cleanup"; adapter: WorkflowAdapter; activation: string; reason: WorkflowTerminalReason; token: symbol };
60
-
61
- const ACTIVATION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
62
-
63
- function explicitSkillActivation(text: string): string | undefined {
64
- if (!text.startsWith("/skill:")) return;
65
- const spaceIndex = text.indexOf(" ");
66
- const activation = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);
67
- return /^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(activation) ? activation : undefined;
68
- }
69
-
70
- function isCancelled(details: QuestionDetails): boolean {
71
- if (details.cancelled) return true;
72
- return "answer" in details && details.answer === null;
73
- }
74
-
75
- function errorMessage(error: unknown): string {
76
- return error instanceof Error ? error.message : String(error);
77
- }
78
-
79
- function publicState(state: InternalState): WorkflowGateState {
80
- if (state.kind === "inactive") return state;
81
- if (state.kind === "pending_decision") {
82
- return {
83
- kind: state.kind,
84
- adapterId: state.adapter.id,
85
- activation: state.activation,
86
- };
87
- }
88
- if (state.kind === "terminal_cleanup") {
89
- return {
90
- kind: state.kind,
91
- adapterId: state.adapter.id,
92
- activation: state.activation,
93
- reason: state.reason,
94
- };
95
- }
96
- return {
97
- kind: state.kind,
98
- adapterId: state.adapter.id,
99
- activation: state.activation,
100
- policyId: state.policy.id,
101
- };
102
- }
103
-
104
- function notify(ctx: ExtensionContext, message: string, type: "info" | "warning" | "error" = "error"): void {
105
- ctx.ui.notify(message, type);
106
- }
107
-
108
- function isKnownPolicy(adapter: WorkflowAdapter, policy: WorkflowPolicy): boolean {
109
- return adapter.policies.includes(policy);
110
- }
111
-
112
- async function invokeCleanup(callback: (() => void | Promise<void>) | undefined): Promise<void> {
113
- try {
114
- await callback?.();
115
- } catch {
116
- // Cleanup callbacks are best effort; the gate must still reach a terminal state.
117
- }
118
- }
119
-
120
- async function invokeCancel(
121
- callback: ((reason: string) => void | Promise<void>) | undefined,
122
- reason: string,
123
- ): Promise<void> {
124
- try {
125
- await callback?.(reason);
126
- } catch {
127
- // Cleanup callbacks are best effort; the gate must still reach a terminal state.
128
- }
129
- }
130
-
131
- async function invokeFailure(
132
- callback: ((error: unknown) => void | Promise<void>) | undefined,
133
- error: unknown,
134
- ): Promise<void> {
135
- try {
136
- await callback?.(error);
137
- } catch {
138
- // Cleanup callbacks are best effort; the gate must still reach a terminal state.
139
- }
140
- }
141
-
142
- export function registerWorkflowGate(
143
- pi: ExtensionAPI,
144
- questionRunner: QuestionRunner,
145
- adapters: readonly WorkflowAdapter[],
146
- ): WorkflowGateController {
147
- const adaptersByActivation = new Map<string, WorkflowAdapter>();
148
- for (const adapter of adapters) {
149
- if (!adapter.id.trim()) throw new Error("Decision-gated workflow adapters require an id");
150
- if (adapter.activations !== undefined && !Array.isArray(adapter.activations)) {
151
- throw new Error(`Workflow adapter ${adapter.id} activations must be an array`);
152
- }
153
- const activations = [
154
- ...(adapter.activation === undefined ? [] : [adapter.activation]),
155
- ...(adapter.activations ?? []),
156
- ];
157
- if (activations.length === 0) {
158
- throw new Error(`Workflow adapter ${adapter.id} must register at least one explicit skill activation`);
159
- }
160
- for (const activation of activations) {
161
- if (typeof activation !== "string" || !ACTIVATION_PATTERN.test(activation)) {
162
- throw new Error(`Invalid decision-gated workflow activation: ${activation}`);
163
- }
164
- if (adaptersByActivation.has(activation)) {
165
- throw new Error(`Duplicate decision-gated workflow activation: ${activation}`);
166
- }
167
- adaptersByActivation.set(activation, adapter);
168
- }
169
- if (adapter.policies.length === 0) throw new Error(`Workflow adapter ${adapter.id} has no policies`);
170
- }
171
-
172
- let state: InternalState = { kind: "inactive" };
173
-
174
- const transitionToCleanup = (
175
- current: Exclude<InternalState, { kind: "inactive" } | { kind: "terminal_cleanup" }>,
176
- reason: WorkflowTerminalReason,
177
- ): symbol => {
178
- state = {
179
- kind: "terminal_cleanup",
180
- adapter: current.adapter,
181
- activation: current.activation,
182
- reason,
183
- token: current.token,
184
- };
185
- if (current.kind === "pending_decision") current.abortController.abort();
186
- return current.token;
187
- };
188
-
189
- const finishCleanup = (token: symbol): void => {
190
- if (state.kind === "terminal_cleanup" && state.token === token) state = { kind: "inactive" };
191
- };
192
-
193
- const finish = async (): Promise<boolean> => {
194
- if (state.kind !== "active") return false;
195
- const active = state;
196
- const token = transitionToCleanup(active, "finish");
197
- await invokeCleanup(active.adapter.onFinish);
198
- finishCleanup(token);
199
- return true;
200
- };
201
-
202
- const cancel = async (reason = "Workflow cancelled"): Promise<boolean> => {
203
- if (state.kind === "inactive" || state.kind === "terminal_cleanup") return false;
204
- const current = state;
205
- const token = transitionToCleanup(current, "cancel");
206
- await invokeCancel(current.adapter.onCancel, reason);
207
- finishCleanup(token);
208
- return true;
209
- };
210
-
211
- const fail = async (
212
- adapter: WorkflowAdapter,
213
- token: symbol,
214
- error: unknown,
215
- ctx: ExtensionContext,
216
- ): Promise<void> => {
217
- if (state.kind === "inactive" || state.kind === "terminal_cleanup") return;
218
- if (state.adapter !== adapter || state.token !== token) return;
219
- const cleanupToken = transitionToCleanup(state, "fail");
220
- await invokeFailure(adapter.onFailure, error);
221
- finishCleanup(cleanupToken);
222
- notify(ctx, `Decision-gated workflow was not activated: ${errorMessage(error)}`);
223
- };
224
-
225
- pi.on("input", async (event, ctx) => {
226
- if ((state.kind === "pending_decision" || state.kind === "terminal_cleanup") && event.text.startsWith("/skill:")) {
227
- notify(ctx, "A decision-gated workflow is waiting for its structured question; skill routing is blocked", "warning");
228
- return { action: "handled" };
229
- }
230
- const activation = explicitSkillActivation(event.text);
231
- if (!activation) return;
232
-
233
- const adapter = adaptersByActivation.get(activation);
234
- if (!adapter) return;
235
-
236
- if (state.kind === "active") {
237
- notify(ctx, `Cannot start /skill:${activation} while a decision-gated workflow is active`, "warning");
238
- return { action: "handled" };
239
- }
240
- if (ctx.mode !== "tui" || !ctx.hasUI) {
241
- notify(ctx, `The /skill:${activation} workflow requires interactive TUI mode`);
242
- return { action: "handled" };
243
- }
244
- if (!ctx.isIdle() || ctx.hasPendingMessages()) {
245
- notify(ctx, `The /skill:${activation} workflow can start only when Pi is idle`, "warning");
246
- return { action: "handled" };
247
- }
248
-
249
- const abortController = new AbortController();
250
- const token = Symbol();
251
- state = { kind: "pending_decision", adapter, activation, abortController, token };
252
- let details: QuestionDetails;
253
- try {
254
- details = await questionRunner.ask(adapter.question, abortController.signal, ctx);
255
- } catch (error) {
256
- if (state.kind !== "pending_decision" || state.adapter !== adapter || state.token !== token) {
257
- return { action: "handled" };
258
- }
259
- await fail(adapter, token, error, ctx);
260
- return { action: "handled" };
261
- }
262
-
263
- if (state.kind !== "pending_decision" || state.adapter !== adapter || state.token !== token) {
264
- return { action: "handled" };
265
- }
266
- if (isCancelled(details)) {
267
- await cancel("Decision question cancelled");
268
- notify(ctx, `The /skill:${activation} workflow was cancelled`, "warning");
269
- return { action: "handled" };
270
- }
271
-
272
- let policy: WorkflowPolicy | undefined;
273
- try {
274
- policy = adapter.selectPolicy(details);
275
- } catch (error) {
276
- await fail(adapter, token, error, ctx);
277
- return { action: "handled" };
278
- }
279
- if (!policy || !isKnownPolicy(adapter, policy)) {
280
- await fail(adapter, token, new Error("The structured answer did not select a registered policy"), ctx);
281
- return { action: "handled" };
282
- }
283
-
284
- try {
285
- await adapter.onActivated?.(policy, details);
286
- } catch (error) {
287
- if (state.kind !== "pending_decision" || state.adapter !== adapter || state.token !== token) {
288
- return { action: "handled" };
289
- }
290
- await fail(adapter, token, error, ctx);
291
- return { action: "handled" };
292
- }
293
- if (state.kind !== "pending_decision" || state.adapter !== adapter || state.token !== token) {
294
- return { action: "handled" };
295
- }
296
- state = { kind: "active", adapter, activation, policy, token };
297
- return { action: "continue" };
298
- });
299
-
300
- pi.on("tool_call", (event: ToolCallEvent, ctx) => {
301
- if (state.kind === "inactive") return;
302
- if (state.kind === "pending_decision" || state.kind === "terminal_cleanup") {
303
- return {
304
- block: true,
305
- reason: "A decision-gated workflow is waiting for its structured question; no model tool calls are allowed yet",
306
- };
307
- }
308
-
309
- const { policy } = state;
310
- if (!policy.allowedTools.includes(event.toolName)) {
311
- return {
312
- block: true,
313
- reason: `Tool ${event.toolName} is not allowed by decision-gated policy ${policy.id}`,
314
- };
315
- }
316
- const authorization = policy.authorizeTool?.(event.toolName, event.input, ctx) ?? true;
317
- if (authorization === true) return;
318
- return {
319
- block: true,
320
- reason: typeof authorization === "string"
321
- ? authorization
322
- : `Tool ${event.toolName} is denied by decision-gated policy ${policy.id}`,
323
- };
324
- });
325
-
326
- const resetForLifecycle = async (reason: string): Promise<void> => {
327
- if (state.kind === "inactive" || state.kind === "terminal_cleanup") return;
328
- const current = state;
329
- const token = transitionToCleanup(current, "session-reset");
330
- await invokeCancel(current.adapter.onCancel, reason);
331
- finishCleanup(token);
332
- };
333
-
334
- pi.on("session_start", () => resetForLifecycle("Session lifecycle reset"));
335
- pi.on("session_shutdown", () => resetForLifecycle("Session lifecycle reset"));
336
- pi.on("session_tree", () => resetForLifecycle("Session tree reset"));
337
- pi.on("session_before_switch", () => resetForLifecycle("Session switch reset"));
338
- pi.on("session_before_fork", () => resetForLifecycle("Session fork reset"));
339
- pi.on("session_before_tree", () => resetForLifecycle("Session tree reset"));
340
-
341
- pi.on("agent_end", async (event, ctx) => {
342
- if (state.kind !== "active") return;
343
-
344
- let failureMessage: string | undefined;
345
- for (let index = event.messages.length - 1; index >= 0; index -= 1) {
346
- const message = event.messages[index];
347
- if (message?.role === "assistant" && message.errorMessage) {
348
- failureMessage = message.errorMessage;
349
- break;
350
- }
351
- }
352
- if (failureMessage === undefined) return;
353
-
354
- const active = state;
355
- await fail(active.adapter, active.token, new Error(failureMessage), ctx);
356
- });
357
-
358
- return {
359
- getState: () => publicState(state),
360
- finish,
361
- cancel,
362
- };
363
- }
364
-
365
- export { explicitSkillActivation };