kritya 0.8.2-beta

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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +434 -0
  3. package/dist/agent/budget.js +25 -0
  4. package/dist/agent/compactor.js +78 -0
  5. package/dist/agent/contextWarning.js +6 -0
  6. package/dist/agent/killSwitch.js +111 -0
  7. package/dist/agent/loop.js +543 -0
  8. package/dist/agent/memory.js +145 -0
  9. package/dist/agent/plugins.js +408 -0
  10. package/dist/agent/skills.js +210 -0
  11. package/dist/agent/skillsCli.js +51 -0
  12. package/dist/agent/systemPrompt.js +122 -0
  13. package/dist/agent/tokens.js +55 -0
  14. package/dist/agent/toolExecutor.js +331 -0
  15. package/dist/agent/workflow.js +496 -0
  16. package/dist/agent/worktree.js +116 -0
  17. package/dist/atomicWrite.js +109 -0
  18. package/dist/audit/audit.js +230 -0
  19. package/dist/audit/cli.js +135 -0
  20. package/dist/commands/custom.js +69 -0
  21. package/dist/commands/mcpCommand.js +433 -0
  22. package/dist/commands/pluginsCommand.js +204 -0
  23. package/dist/commands/registry.js +739 -0
  24. package/dist/config/config.js +214 -0
  25. package/dist/config/debug.js +19 -0
  26. package/dist/config/models.js +82 -0
  27. package/dist/config/retention.js +24 -0
  28. package/dist/config/winAcl.js +38 -0
  29. package/dist/crash.js +102 -0
  30. package/dist/engine.js +113 -0
  31. package/dist/git/git.js +46 -0
  32. package/dist/headless.js +274 -0
  33. package/dist/hooks/hooks.js +122 -0
  34. package/dist/index.js +670 -0
  35. package/dist/lsp/client.js +413 -0
  36. package/dist/lsp/manager.js +0 -0
  37. package/dist/lsp/registry.js +62 -0
  38. package/dist/mcp/callback.js +141 -0
  39. package/dist/mcp/client.js +945 -0
  40. package/dist/mcp/login.js +117 -0
  41. package/dist/mcp/oauth.js +345 -0
  42. package/dist/mcp/servers.js +114 -0
  43. package/dist/mcp/spawnWin.js +86 -0
  44. package/dist/mcp/tokens.js +97 -0
  45. package/dist/mcp/transport.js +295 -0
  46. package/dist/net/urlSafety.js +158 -0
  47. package/dist/permissions/danger.js +96 -0
  48. package/dist/permissions/permissions.js +62 -0
  49. package/dist/permissions/rules.js +69 -0
  50. package/dist/plugins/discover.js +108 -0
  51. package/dist/plugins/mcp.js +80 -0
  52. package/dist/provider/client.js +460 -0
  53. package/dist/provider/switchyardClient.js +46 -0
  54. package/dist/provider/switchyardSidecar.js +245 -0
  55. package/dist/provider/textToolCalls.js +140 -0
  56. package/dist/repomap/repoMap.js +168 -0
  57. package/dist/repomap/symbols.js +190 -0
  58. package/dist/session/store.js +328 -0
  59. package/dist/shell/background.js +131 -0
  60. package/dist/shell/sandbox.js +396 -0
  61. package/dist/telemetry/metrics.js +172 -0
  62. package/dist/telemetry/otlp.js +128 -0
  63. package/dist/telemetry/tracer.js +240 -0
  64. package/dist/tools/askUser.js +64 -0
  65. package/dist/tools/bg.js +59 -0
  66. package/dist/tools/common.js +158 -0
  67. package/dist/tools/deepResearch.js +126 -0
  68. package/dist/tools/diff.js +70 -0
  69. package/dist/tools/document/docx.js +44 -0
  70. package/dist/tools/document/pdf.js +190 -0
  71. package/dist/tools/document/pptx.js +148 -0
  72. package/dist/tools/document/types.js +26 -0
  73. package/dist/tools/document/xlsx.js +87 -0
  74. package/dist/tools/document.js +389 -0
  75. package/dist/tools/edit.js +56 -0
  76. package/dist/tools/fetchUrl.js +218 -0
  77. package/dist/tools/fuzzyMatch.js +74 -0
  78. package/dist/tools/glob.js +34 -0
  79. package/dist/tools/grep.js +85 -0
  80. package/dist/tools/ignore.js +28 -0
  81. package/dist/tools/index.js +66 -0
  82. package/dist/tools/ls.js +27 -0
  83. package/dist/tools/lsp.js +239 -0
  84. package/dist/tools/notebook.js +215 -0
  85. package/dist/tools/read.js +32 -0
  86. package/dist/tools/repoMap.js +26 -0
  87. package/dist/tools/secretScan.js +143 -0
  88. package/dist/tools/shell.js +134 -0
  89. package/dist/tools/skills.js +69 -0
  90. package/dist/tools/subagent.js +57 -0
  91. package/dist/tools/tasks.js +43 -0
  92. package/dist/tools/webSearch.js +97 -0
  93. package/dist/tools/write.js +47 -0
  94. package/dist/tools/writeAgent.js +72 -0
  95. package/dist/trust/aiDisclosure.js +45 -0
  96. package/dist/trust/mcpTrust.js +111 -0
  97. package/dist/trust/trust.js +231 -0
  98. package/dist/types.js +1 -0
  99. package/dist/ui/AiDisclosurePrompt.js +23 -0
  100. package/dist/ui/App.js +449 -0
  101. package/dist/ui/Banner.js +71 -0
  102. package/dist/ui/ElicitationPrompt.js +46 -0
  103. package/dist/ui/Markdown.js +148 -0
  104. package/dist/ui/McpTrustPrompt.js +42 -0
  105. package/dist/ui/ModelPicker.js +19 -0
  106. package/dist/ui/PermissionPrompt.js +27 -0
  107. package/dist/ui/SelectList.js +24 -0
  108. package/dist/ui/Spinner.js +12 -0
  109. package/dist/ui/StatusLine.js +11 -0
  110. package/dist/ui/TranscriptItem.js +13 -0
  111. package/dist/ui/TrustPrompt.js +12 -0
  112. package/dist/ui/highlight.js +137 -0
  113. package/dist/ui/inline.js +241 -0
  114. package/dist/ui/mermaid.js +98 -0
  115. package/dist/ui/table.js +148 -0
  116. package/dist/ui/toolOutputPreview.js +34 -0
  117. package/dist/ui/useAgent.js +534 -0
  118. package/dist/ui/useKillSwitch.js +65 -0
  119. package/dist/ui/useSessionResume.js +39 -0
  120. package/dist/ui/useUsageBudget.js +149 -0
  121. package/dist/ui/viewport.js +71 -0
  122. package/dist/undo/undo.js +293 -0
  123. package/dist/version.js +4 -0
  124. package/package.json +122 -0
@@ -0,0 +1,739 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { AuditLog, summarizeAudit } from "../audit/audit.js";
4
+ import { runMcpCommand } from "./mcpCommand.js";
5
+ import { mcpPrompts } from "../mcp/client.js";
6
+ import { scanSkillsDetailed, skillsDir, userSkillsDir } from "../agent/skills.js";
7
+ import { pluginsDir, pluginSkillsRoots, scanPlugins, scanPluginsDetailed, userPluginsDir, } from "../plugins/discover.js";
8
+ import { scanPluginMcpServers } from "../plugins/mcp.js";
9
+ import { listProviders } from "../config/config.js";
10
+ import { expandCommand } from "./custom.js";
11
+ import { artifactPath, clearProjectState, loadProjectState, parseIdea, parsePhase, phaseBlocker, phasePrompt, renameProject, staleArtifacts, PHASE_COMMAND, PHASE_ORDER, PHASE_SUMMARY, saveProjectState, slugify, } from "../agent/workflow.js";
12
+ export const BUILTIN_COMMANDS = [
13
+ { name: "/help", description: "show available commands" },
14
+ { name: "/model", description: "pick a model, or /model <id> for any provider model ID" },
15
+ {
16
+ name: "/provider",
17
+ description: "list providers, or /provider <name> to switch mid-session (keeps history); add --default to persist",
18
+ },
19
+ {
20
+ name: "/flow-brainstorm",
21
+ description: "start a new-project workflow: /flow-brainstorm <idea> (brainstorm→spec→plan→build→review→fix)",
22
+ },
23
+ {
24
+ name: "/flow-spec",
25
+ description: "project workflow: write the spec from the approved brainstorm",
26
+ },
27
+ { name: "/plan", description: "toggle plan mode: /plan, /plan on, /plan off" },
28
+ { name: "/flow-plan", description: "project workflow: plan from the spec (read-only)" },
29
+ { name: "/flow-build", description: "project workflow: implement the plan, with tests" },
30
+ { name: "/flow-review", description: "project workflow: spec-compliance and security review" },
31
+ { name: "/flow-fix", description: "project workflow: fix the review's findings, re-verified" },
32
+ {
33
+ name: "/project",
34
+ description: "workflow status · /project goto <phase> · rename <name> · clear",
35
+ },
36
+ { name: "/diff", description: "show the cumulative git diff of this session's changes" },
37
+ { name: "/redo", description: "reapply the change most recently undone" },
38
+ { name: "/init", description: "scan the repo and generate a KRITYA.md project-memory file" },
39
+ { name: "/web-search", description: "search the web: /web-search <query>" },
40
+ {
41
+ name: "/mcp",
42
+ description: "MCP servers: status, /mcp add|remove <name>, /mcp login|logout <name>",
43
+ },
44
+ {
45
+ name: "/skills",
46
+ description: "list discovered skills (project + user-global) and why any were skipped",
47
+ },
48
+ {
49
+ name: "/plugins",
50
+ description: "list discovered Agent Plugins, what each contributes, and why any were skipped",
51
+ },
52
+ { name: "/undo", description: "revert the file changes from the agent's last turn" },
53
+ {
54
+ name: "/checkpoint",
55
+ description: "save a named point: /checkpoint <name> (no name lists saved ones)",
56
+ },
57
+ {
58
+ name: "/rewind",
59
+ description: "rewind the conversation and files to a checkpoint: /rewind <name>",
60
+ },
61
+ { name: "/commit", description: "have the agent stage and commit the current changes" },
62
+ { name: "/compact", description: "summarize older conversation to free context space" },
63
+ { name: "/clear", description: "start a fresh conversation" },
64
+ { name: "/cost", description: "show token usage and estimated cost" },
65
+ {
66
+ name: "/audit",
67
+ description: "show this session's permission decisions and verify the audit log's chain",
68
+ },
69
+ {
70
+ name: "/budget",
71
+ description: "show session token budget, /budget reset, or /budget <number> to set it",
72
+ },
73
+ {
74
+ name: "/kill",
75
+ description: "emergency stop: /kill [reason] halts everything · /kill off releases (Ctrl+K)",
76
+ },
77
+ { name: "/exit", description: "leave" },
78
+ { name: "/quit", description: "leave" },
79
+ ];
80
+ export const HELP_TEXT = `Commands:
81
+ ${BUILTIN_COMMANDS.map((c) => ` ${c.name.padEnd(14)} ${c.description}`).join("\n")}
82
+
83
+ Also: @path/to/file attaches a file to your message (with autocomplete).
84
+ @image.png attaches an image for vision-capable models.
85
+ @mcp:server/name attaches a document an MCP server offers.
86
+ MCP servers can also contribute their own /server-prompt commands.
87
+ Project memory: put standing instructions in KRITYA.md at your workspace root.
88
+ Keys: Esc cancels · Tab completes · Shift+Tab cycles normal/accept-edits/dry-run
89
+ mode · ↑/↓ recalls history · Ctrl+O toggles full tool output · Ctrl+K is the
90
+ kill switch (stops everything until /kill off) · Ctrl+C exits`;
91
+ /**
92
+ * A phase boundary is the one place compaction is unambiguously safe: the
93
+ * phase that just ended wrote its conclusions to a durable artifact on disk,
94
+ * so the transcript that produced them is redundant. Compacting here is what
95
+ * keeps a five-phase project from dragging every phase's exploration into the
96
+ * next phase's context window.
97
+ */
98
+ async function compactAtPhaseBoundary(ctx, phase) {
99
+ // Compaction is a full model call, so the UI has to show it. Without this the
100
+ // terminal sits silent — the spinner only renders while the phase is
101
+ // "working", and the elapsed-time counter is gated the same way.
102
+ ctx.setPhase("working");
103
+ ctx.setActivity(`Compacting before the ${phase} phase…`);
104
+ try {
105
+ const note = await ctx.agent.compact();
106
+ if (/^nothing to compact/i.test(note))
107
+ return;
108
+ ctx.addItem({ kind: "info", text: note });
109
+ ctx.setCtxPct(Math.round(ctx.agent.contextUsage() * 100));
110
+ }
111
+ catch {
112
+ // Compaction is a cost optimization. Failing it must never cost the user
113
+ // their phase — the phase runs either way.
114
+ }
115
+ finally {
116
+ // runAgent takes the phase from here; only the label needs clearing, or it
117
+ // would outlive the compaction it describes.
118
+ ctx.setActivity(null);
119
+ }
120
+ }
121
+ /** Strip a trailing/leading `--force` from a command argument. */
122
+ function takeForce(arg) {
123
+ const force = /(^|\s)--force(\s|$)/.test(arg);
124
+ return { arg: arg.replace(/(^|\s)--force(\s|$)/, " ").trim(), force };
125
+ }
126
+ /** Strip a trailing/leading `--default` from a command argument. */
127
+ function takeDefault(arg) {
128
+ const makeDefault = /(^|\s)--default(\s|$)/.test(arg);
129
+ return { arg: arg.replace(/(^|\s)--default(\s|$)/, " ").trim(), makeDefault };
130
+ }
131
+ /**
132
+ * Run one workflow phase: check its prerequisite artifact exists, set the mode
133
+ * the phase needs, record the phase, compact, then hand the prompt to the agent.
134
+ */
135
+ async function runPhase(ctx, phase) {
136
+ const project = loadProjectState(ctx.workspace);
137
+ if (!project) {
138
+ ctx.addItem({
139
+ kind: "info",
140
+ text: "No active project workflow. Start one with /flow-brainstorm <idea>.",
141
+ });
142
+ return;
143
+ }
144
+ const { arg, force } = takeForce(ctx.arg);
145
+ const blocker = phaseBlocker(ctx.workspace, project.name, phase);
146
+ if (blocker && !force) {
147
+ ctx.addItem({
148
+ kind: "info",
149
+ text: `${blocker}\nTo run ${PHASE_COMMAND[phase]} anyway: ${PHASE_COMMAND[phase]} --force`,
150
+ });
151
+ return;
152
+ }
153
+ // Warn (never block) if an earlier artifact was edited after this phase's
154
+ // input was written from it — e.g. spec.md changed after plan.md already
155
+ // read it. The phase still runs on whatever's on disk right now.
156
+ const stale = staleArtifacts(ctx.workspace, project.name, phase);
157
+ if (stale.length) {
158
+ ctx.addItem({ kind: "info", text: stale.join("\n") });
159
+ }
160
+ // The plan phase is read-only by design; every other phase needs to write.
161
+ const wantPlanMode = phase === "plan";
162
+ if (ctx.planMode !== wantPlanMode) {
163
+ ctx.agent.planMode = wantPlanMode;
164
+ ctx.setPlanMode(wantPlanMode);
165
+ }
166
+ if (wantPlanMode && ctx.acceptEdits) {
167
+ ctx.agent.acceptEdits = false;
168
+ ctx.setAcceptEdits(false);
169
+ }
170
+ saveProjectState(ctx.workspace, project.name, phase);
171
+ const artifact = artifactPath(project.name, phase);
172
+ ctx.addItem({
173
+ kind: "info",
174
+ text: `${phase} phase for "${project.name}" — ${PHASE_SUMMARY[phase]}` +
175
+ (artifact ? ` → ${artifact}` : "") +
176
+ (wantPlanMode ? " (plan mode ON: read-only apart from that doc)" : "") +
177
+ (blocker && force ? "\n⚠ Forced past a missing prerequisite." : ""),
178
+ });
179
+ ctx.addItem({ kind: "user", text: ctx.raw.trim() });
180
+ ctx.setRunningPhase(phase);
181
+ ctx.refreshWorkflow();
182
+ await compactAtPhaseBoundary(ctx, phase);
183
+ return ctx.runAgent(phasePrompt(project.name, phase, arg));
184
+ }
185
+ const handlers = {
186
+ "/help": (ctx) => {
187
+ const customList = ctx.customCommands.length
188
+ ? `\n\nCustom commands (from .kritya/commands/):\n${ctx.customCommands
189
+ .map((c) => ` ${c.name.padEnd(14)} ${c.description}${c.pluginName ? ` (plugin: ${c.pluginName})` : ""}`)
190
+ .join("\n")}`
191
+ : "";
192
+ const mcpNote = ctx.mcpToolCount > 0 ? `\n\n${ctx.mcpToolCount} MCP tool(s) loaded.` : "";
193
+ ctx.addItem({ kind: "info", text: HELP_TEXT + customList + mcpNote });
194
+ },
195
+ "/model": (ctx) => {
196
+ if (ctx.arg)
197
+ ctx.setModelEverywhere(ctx.arg);
198
+ else
199
+ ctx.setPhase("model");
200
+ },
201
+ "/provider": (ctx) => {
202
+ const { arg, makeDefault } = takeDefault(ctx.arg);
203
+ if (!arg) {
204
+ const lines = listProviders(ctx.config).map((p) => {
205
+ const marker = p.name === ctx.provider ? "*" : " ";
206
+ const note = p.hasKey ? "" : " (no API key configured)";
207
+ return ` ${marker} ${p.name}${note}`;
208
+ });
209
+ ctx.addItem({
210
+ kind: "info",
211
+ text: `Providers (* = active):\n${lines.join("\n")}\n\n` +
212
+ `Switch with /provider <name> — session only, conversation history is kept. ` +
213
+ `Add --default to also make it the default for future launches. ` +
214
+ `If a request keeps failing (429/5xx after retries), switch to any provider marked with a key.`,
215
+ });
216
+ return;
217
+ }
218
+ ctx.setProviderEverywhere(arg, makeDefault);
219
+ },
220
+ "/mcp": (ctx) => {
221
+ // Bare /mcp is read-only and stays available while the kill switch is
222
+ // engaged (see ALLOWED_WHILE_KILLED); its subcommands connect servers,
223
+ // write config, and mint tokens, so they are not.
224
+ if (ctx.killed && ctx.arg.trim()) {
225
+ ctx.addItem({
226
+ kind: "info",
227
+ text: `⛔ Kill switch ACTIVE${ctx.killReason ? ` — ${ctx.killReason}` : ""}. ` +
228
+ `/mcp ${ctx.arg.trim().split(/\s+/)[0]} is blocked; plain /mcp still works. Release it with /kill off.`,
229
+ });
230
+ return;
231
+ }
232
+ return runMcpCommand(ctx);
233
+ },
234
+ "/skills": (ctx) => {
235
+ const projectRoot = skillsDir(ctx.workspace);
236
+ const userRoot = userSkillsDir();
237
+ const plugins = scanPlugins([pluginsDir(ctx.workspace), userPluginsDir()]);
238
+ const pluginRoots = pluginSkillsRoots(plugins);
239
+ const { loaded, skipped } = scanSkillsDetailed([
240
+ projectRoot,
241
+ userRoot,
242
+ ...pluginRoots.map((r) => r.dir),
243
+ ]);
244
+ if (!loaded.length && !skipped.length) {
245
+ ctx.addItem({
246
+ kind: "info",
247
+ text: `No skills found under ${projectRoot} or ${userRoot}.`,
248
+ });
249
+ return;
250
+ }
251
+ const truncate = (s, max) => (s.length > max ? `${s.slice(0, max - 1)}…` : s);
252
+ const rows = loaded.map((s) => {
253
+ const plugin = pluginRoots.find((r) => s.dir === r.dir || s.dir.startsWith(r.dir + path.sep));
254
+ const source = plugin
255
+ ? `plugin: ${plugin.pluginName}`
256
+ : s.dir.startsWith(projectRoot + path.sep) || s.dir === projectRoot
257
+ ? "project"
258
+ : "user";
259
+ return { name: s.name, source, description: truncate(s.description, 60) };
260
+ });
261
+ const nameWidth = Math.max(4, ...rows.map((r) => r.name.length), ...skipped.map((s) => s.name.length));
262
+ const lines = [
263
+ ...rows.map((r) => ` ${r.name.padEnd(nameWidth)} (${r.source}) ${r.description}`),
264
+ ...skipped.map((s) => ` ${s.name.padEnd(nameWidth)} SKIPPED: ${s.reason}`),
265
+ ];
266
+ ctx.addItem({ kind: "info", text: lines.join("\n") });
267
+ },
268
+ "/plugins": (ctx) => {
269
+ const workspaceRoot = pluginsDir(ctx.workspace);
270
+ const userRoot = userPluginsDir();
271
+ const { loaded, skipped } = scanPluginsDetailed([workspaceRoot, userRoot]);
272
+ if (!loaded.length && !skipped.length) {
273
+ ctx.addItem({
274
+ kind: "info",
275
+ text: `No plugins found under ${workspaceRoot} or ${userRoot}.`,
276
+ });
277
+ return;
278
+ }
279
+ const rows = loaded.map((p) => {
280
+ const source = p.dir.startsWith(workspaceRoot + path.sep) || p.dir === workspaceRoot
281
+ ? "workspace"
282
+ : "user";
283
+ const version = typeof p.manifest.version === "string" ? p.manifest.version : "?";
284
+ const { loaded: skillsLoaded } = scanSkillsDetailed([path.join(p.dir, "skills")]);
285
+ const { loaded: mcpLoaded, skipped: mcpSkipped } = scanPluginMcpServers([p]);
286
+ let commandCount = 0;
287
+ try {
288
+ commandCount = fs
289
+ .readdirSync(path.join(p.dir, "commands"))
290
+ .filter((f) => f.endsWith(".md")).length;
291
+ }
292
+ catch {
293
+ // No commands/ subfolder -- not a mistake, just nothing to count.
294
+ }
295
+ const contributions = [
296
+ skillsLoaded.length ? `${skillsLoaded.length} skill(s)` : "",
297
+ mcpLoaded.length ? `${mcpLoaded.length} MCP server(s)` : "",
298
+ commandCount ? `${commandCount} command(s)` : "",
299
+ ].filter(Boolean);
300
+ const mcpSkipNote = mcpSkipped.length
301
+ ? ` — skipped MCP server(s): ${mcpSkipped.map((s) => `${s.name} (${s.reason})`).join("; ")}`
302
+ : "";
303
+ return ` ${p.name}@${version} (${source}) ${contributions.join(", ") || "(nothing)"}${mcpSkipNote}`;
304
+ });
305
+ const skipLines = skipped.map((s) => ` ${s.name} SKIPPED: ${s.reason}`);
306
+ ctx.addItem({ kind: "info", text: [...rows, ...skipLines].join("\n") });
307
+ },
308
+ "/web-search": (ctx) => {
309
+ if (!ctx.arg) {
310
+ ctx.addItem({ kind: "info", text: "Usage: /web-search <query>" });
311
+ return;
312
+ }
313
+ return ctx.runWebSearch(ctx.arg);
314
+ },
315
+ "/undo": (ctx) => {
316
+ const result = ctx.undoStack.undo();
317
+ if (result === null) {
318
+ ctx.addItem({ kind: "info", text: "Nothing to undo." });
319
+ }
320
+ else {
321
+ ctx.addItem({ kind: "info", text: `Undo: ${result}` });
322
+ ctx.agent.addUserNote(`[I reverted your last file change via /undo: ${result}]`);
323
+ ctx.refreshFileList();
324
+ }
325
+ },
326
+ "/checkpoint": (ctx) => {
327
+ const name = ctx.arg.trim();
328
+ if (!name) {
329
+ const list = ctx.agent.listCheckpoints();
330
+ if (!list.length) {
331
+ ctx.addItem({
332
+ kind: "info",
333
+ text: "No checkpoints yet. Save one with /checkpoint <name>.",
334
+ });
335
+ return;
336
+ }
337
+ const lines = list.map((c) => ` ${c.name} (${new Date(c.createdAt).toLocaleTimeString()})`);
338
+ ctx.addItem({
339
+ kind: "info",
340
+ text: `Checkpoints:\n${lines.join("\n")}\n\nRewind to one with /rewind <name>.`,
341
+ });
342
+ return;
343
+ }
344
+ ctx.agent.saveCheckpoint(name, ctx.undoStack.currentTurn());
345
+ ctx.addItem({
346
+ kind: "info",
347
+ text: `Saved checkpoint "${name}". Rewind here later with /rewind ${name}.`,
348
+ });
349
+ },
350
+ "/rewind": (ctx) => {
351
+ const name = ctx.arg.trim();
352
+ if (!name) {
353
+ ctx.addItem({
354
+ kind: "info",
355
+ text: "Usage: /rewind <name>. List saved points with /checkpoint.",
356
+ });
357
+ return;
358
+ }
359
+ const cp = ctx.agent.getCheckpoint(name);
360
+ if (!cp) {
361
+ ctx.addItem({
362
+ kind: "info",
363
+ text: `No checkpoint named "${name}". See /checkpoint for the list.`,
364
+ });
365
+ return;
366
+ }
367
+ // Roll files back first, then trim the conversation to the same point.
368
+ const fileResult = ctx.undoStack.rewindTo(cp.undoTurn);
369
+ ctx.agent.truncateHistory(cp.historyLength);
370
+ const filePart = fileResult ? `\n${fileResult}` : "\nNo file changes to roll back.";
371
+ ctx.addItem({ kind: "info", text: `Rewound to "${name}".${filePart}` });
372
+ ctx.refreshFileList();
373
+ },
374
+ "/clear": (ctx) => {
375
+ ctx.agent.reset();
376
+ ctx.setTasks([]);
377
+ ctx.addItem({ kind: "info", text: "Conversation cleared." });
378
+ },
379
+ "/cost": (ctx) => {
380
+ ctx.addItem({ kind: "info", text: ctx.costReport() });
381
+ },
382
+ "/audit": (ctx) => {
383
+ const audit = ctx.agent.audit;
384
+ if (!audit) {
385
+ ctx.addItem({
386
+ kind: "info",
387
+ text: "Auditing is off for this session (KRITYA_AUDIT=off).",
388
+ });
389
+ return;
390
+ }
391
+ const records = AuditLog.readRecords(audit.path);
392
+ if (!records.length) {
393
+ ctx.addItem({ kind: "info", text: `No audit records yet.\nLog: ${audit.path}` });
394
+ return;
395
+ }
396
+ const fmt = (m) => Object.entries(m)
397
+ .sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0))
398
+ .map(([k, v]) => `${k}: ${v}`)
399
+ .join(", ") || "(none)";
400
+ const verify = AuditLog.verify(audit.path);
401
+ const chainLine = verify.ok
402
+ ? `chain verified — ${verify.records} record(s)`
403
+ : verify.reason === "unreadable"
404
+ ? "chain check FAILED — log file could not be read"
405
+ : `chain check FAILED — broken at record ${verify.line}`;
406
+ const s = summarizeAudit(records);
407
+ const latencyLine = s.durationMsP50 || s.waitMsP50
408
+ ? `Tool latency: p50 ${s.durationMsP50}ms / p95 ${s.durationMsP95}ms — ` +
409
+ `permission wait: p50 ${s.waitMsP50}ms / p95 ${s.waitMsP95}ms\n`
410
+ : "";
411
+ ctx.addItem({
412
+ kind: "info",
413
+ text: `Audit log: ${audit.path}\n` +
414
+ `${chainLine}\n\n` +
415
+ `Permission decisions by source: ${fmt(s.permissionsBySource)}\n` +
416
+ `Tool outcomes: ${fmt(s.toolCallsByOutcome)}\n` +
417
+ latencyLine +
418
+ `\nFull history: kritya audit --show ${path.basename(audit.path)}`,
419
+ });
420
+ },
421
+ "/budget": (ctx) => {
422
+ const arg = ctx.arg.trim().toLowerCase();
423
+ if (!arg) {
424
+ const status = ctx.budgetStopped ? " — STOPPED" : "";
425
+ ctx.addItem({
426
+ kind: "info",
427
+ text: `Token budget: ${ctx.budgetUsed.toLocaleString()} / ${ctx.tokenBudget.toLocaleString()} ` +
428
+ `(${ctx.budgetPct}%)${status}\nUsage: /budget reset · /budget <number>`,
429
+ });
430
+ return;
431
+ }
432
+ if (arg === "reset") {
433
+ ctx.resetBudget();
434
+ return;
435
+ }
436
+ const n = Number(arg.replace(/[,_]/g, ""));
437
+ if (!Number.isFinite(n) || n <= 0) {
438
+ ctx.addItem({
439
+ kind: "info",
440
+ text: `Invalid budget "${ctx.arg}". Use /budget <number>, e.g. /budget 2000000.`,
441
+ });
442
+ return;
443
+ }
444
+ ctx.setBudgetLimit(Math.round(n));
445
+ },
446
+ "/compact": (ctx) => {
447
+ ctx.setPhase("working");
448
+ ctx.setActivity("Compacting context…");
449
+ return ctx.agent
450
+ .compact()
451
+ .then((note) => {
452
+ ctx.addItem({ kind: "info", text: note });
453
+ ctx.setCtxPct(Math.round(ctx.agent.contextUsage() * 100));
454
+ })
455
+ .catch((err) => ctx.addItem({
456
+ kind: "info",
457
+ text: `Compaction failed: ${err instanceof Error ? err.message : String(err)}`,
458
+ }))
459
+ .finally(() => {
460
+ ctx.setActivity(null);
461
+ ctx.setPhase("input");
462
+ });
463
+ },
464
+ "/init": (ctx) => {
465
+ ctx.addItem({ kind: "user", text: "/init" });
466
+ return ctx.runAgent("Explore this repository (README, package/build files, src layout, test setup) and write " +
467
+ "a concise KRITYA.md at the workspace root: what the project is, key commands " +
468
+ "(build/test/run), architecture in 5-10 bullets, and conventions a coding agent must " +
469
+ "follow when working here. Keep it under 60 lines.");
470
+ },
471
+ "/commit": (ctx) => {
472
+ ctx.addItem({ kind: "user", text: "/commit" });
473
+ const attribution = ctx.config.commitAttribution !== false;
474
+ const trailerInstruction = attribution
475
+ ? ` End the commit message with a trailer on its own line: ` +
476
+ `"Generated-By: kritya (${ctx.provider}/${ctx.model})".`
477
+ : "";
478
+ return ctx.runAgent("Review the current git changes (git status, git diff), stage the appropriate files, " +
479
+ "and create a commit with a well-written conventional-commit message that describes " +
480
+ `the change.${trailerInstruction} Do not push. Show the final commit hash and message.`);
481
+ },
482
+ "/flow-brainstorm": async (ctx) => {
483
+ const input = ctx.arg.trim();
484
+ const existing = loadProjectState(ctx.workspace);
485
+ if (!existing && !input) {
486
+ ctx.addItem({
487
+ kind: "info",
488
+ text: `Usage: /flow-brainstorm <your project idea>. Starts the ${PHASE_ORDER.join(" → ")} workflow.\n` +
489
+ `Name it yourself with a short prefix: /flow-brainstorm reverser: a script that reverses a string`,
490
+ });
491
+ return;
492
+ }
493
+ // An idea always names the project. Reusing the existing project's name for
494
+ // a freshly described idea (the old behaviour) silently wrote one project's
495
+ // brainstorm into another project's folder.
496
+ const parsed = parseIdea(input);
497
+ const idea = input ? parsed.idea : "";
498
+ const name = input ? slugify(parsed.name) : existing.name;
499
+ if (existing && existing.name !== name) {
500
+ ctx.addItem({
501
+ kind: "info",
502
+ text: `Starting a new project "${name}". The previous project "${existing.name}" was in the ` +
503
+ `${existing.phase} phase; its docs/${existing.name}/ artifacts are untouched, and ` +
504
+ `/project goto ${existing.phase} after /flow-brainstorm ${existing.name} returns to it.`,
505
+ });
506
+ }
507
+ saveProjectState(ctx.workspace, name, "brainstorm");
508
+ // Brainstorming writes its doc, so make sure plan mode isn't left on.
509
+ if (ctx.planMode) {
510
+ ctx.agent.planMode = false;
511
+ ctx.setPlanMode(false);
512
+ }
513
+ ctx.addItem({ kind: "user", text: ctx.raw.trim() });
514
+ ctx.setRunningPhase("brainstorm");
515
+ ctx.refreshWorkflow();
516
+ // A no-op at the start of a session; earns its keep when a second project
517
+ // is started in a session that already ran one.
518
+ await compactAtPhaseBoundary(ctx, "brainstorm");
519
+ return ctx.runAgent(phasePrompt(name, "brainstorm", idea));
520
+ },
521
+ // `/plan` (no `flow-` prefix) is pure plan-mode control — on/off or a bare
522
+ // toggle — and never touches the project workflow. `/flow-plan` runs the
523
+ // workflow's plan phase. Splitting these removes the ambiguity the old
524
+ // combined `/plan` had, where a bare call meant different things depending
525
+ // on whether a project happened to be active.
526
+ "/plan": (ctx) => {
527
+ const explicit = ctx.arg.trim().toLowerCase();
528
+ const next = explicit ? explicit === "on" : !ctx.planMode;
529
+ ctx.setPlanMode(next);
530
+ ctx.agent.planMode = next;
531
+ // Plan mode and accept-edits are mutually exclusive; entering one via
532
+ // either path must turn the other off.
533
+ if (next && ctx.acceptEdits) {
534
+ ctx.agent.acceptEdits = false;
535
+ ctx.setAcceptEdits(false);
536
+ }
537
+ ctx.addItem({
538
+ kind: "info",
539
+ text: next
540
+ ? "Plan mode ON — read-only. The agent will explore and propose a plan; edits and shell are blocked. Run /plan off to execute."
541
+ : "Plan mode OFF — the agent can make changes again.",
542
+ });
543
+ },
544
+ "/flow-spec": (ctx) => runPhase(ctx, "spec"),
545
+ "/flow-plan": (ctx) => runPhase(ctx, "plan"),
546
+ "/flow-build": (ctx) => runPhase(ctx, "build"),
547
+ "/flow-review": (ctx) => runPhase(ctx, "review"),
548
+ "/flow-fix": (ctx) => runPhase(ctx, "fix"),
549
+ "/project": (ctx) => {
550
+ const [sub = "", ...rest] = ctx.arg.trim().split(/\s+/);
551
+ const project = loadProjectState(ctx.workspace);
552
+ if (sub.toLowerCase() === "clear") {
553
+ if (!project) {
554
+ ctx.addItem({ kind: "info", text: "No active project workflow." });
555
+ return;
556
+ }
557
+ clearProjectState(ctx.workspace);
558
+ ctx.refreshWorkflow();
559
+ ctx.addItem({
560
+ kind: "info",
561
+ text: `Workflow for "${project.name}" ended. Its docs/${project.name}/ artifacts are kept.`,
562
+ });
563
+ return;
564
+ }
565
+ if (!project) {
566
+ ctx.addItem({
567
+ kind: "info",
568
+ text: "No active project workflow. Start one with /flow-brainstorm <idea>.",
569
+ });
570
+ return;
571
+ }
572
+ if (sub.toLowerCase() === "rename") {
573
+ const result = renameProject(ctx.workspace, project.name, rest.join(" "));
574
+ if (!result.ok) {
575
+ ctx.addItem({ kind: "info", text: result.error });
576
+ return;
577
+ }
578
+ ctx.refreshWorkflow();
579
+ ctx.addItem({
580
+ kind: "info",
581
+ text: `Renamed "${project.name}" to "${result.name}" — artifacts moved to docs/${result.name}/.`,
582
+ });
583
+ return;
584
+ }
585
+ if (sub.toLowerCase() === "goto") {
586
+ const target = parsePhase(rest.join(" "));
587
+ if (!target) {
588
+ ctx.addItem({ kind: "info", text: `Usage: /project goto <${PHASE_ORDER.join("|")}>` });
589
+ return;
590
+ }
591
+ saveProjectState(ctx.workspace, project.name, target);
592
+ ctx.refreshWorkflow();
593
+ ctx.addItem({
594
+ kind: "info",
595
+ text: `"${project.name}" set to the ${target} phase. Run ${PHASE_COMMAND[target]} to start it.`,
596
+ });
597
+ return;
598
+ }
599
+ const lines = PHASE_ORDER.map((p) => {
600
+ const marker = p === project.phase ? "*" : " ";
601
+ const artifact = artifactPath(project.name, p);
602
+ return ` ${marker} ${p.padEnd(11)}${PHASE_SUMMARY[p]}\n ${artifact ?? "(application code)"}`;
603
+ });
604
+ const stale = staleArtifacts(ctx.workspace, project.name, project.phase);
605
+ const staleBlock = stale.length ? `\n\n${stale.join("\n")}` : "";
606
+ ctx.addItem({
607
+ kind: "info",
608
+ text: `Project "${project.name}" — ${project.phase} phase` +
609
+ (project.updatedAt ? ` (since ${project.updatedAt.slice(0, 10)})` : "") +
610
+ `\n\n${lines.join("\n")}` +
611
+ staleBlock +
612
+ `\n\nRun ${PHASE_COMMAND[project.phase]} to continue · /project goto <phase> to move · ` +
613
+ `/project rename <name> · /project clear to end the workflow.`,
614
+ });
615
+ },
616
+ "/diff": (ctx) => {
617
+ const d = ctx.gitDiffStat(ctx.workspace);
618
+ ctx.addItem({ kind: "info", text: d || "No git changes (or not a git repo)." });
619
+ },
620
+ "/redo": (ctx) => {
621
+ const result = ctx.undoStack.redo();
622
+ if (result === null) {
623
+ ctx.addItem({ kind: "info", text: "Nothing to redo." });
624
+ }
625
+ else {
626
+ ctx.addItem({ kind: "info", text: `Redo: ${result}` });
627
+ ctx.agent.addUserNote(`[I reapplied a previously undone change via /redo: ${result}]`);
628
+ ctx.refreshFileList();
629
+ }
630
+ },
631
+ /**
632
+ * The emergency stop. `/kill off` (or release/clear/resume) is the only way
633
+ * back; anything else is treated as the reason it was pulled, which is what
634
+ * shows up in the audit log and on every subsequent refusal.
635
+ */
636
+ "/kill": (ctx) => {
637
+ const arg = ctx.arg.trim();
638
+ const word = arg.toLowerCase();
639
+ if (word === "off" || word === "release" || word === "clear" || word === "resume") {
640
+ ctx.releaseKill();
641
+ return;
642
+ }
643
+ if (word === "status") {
644
+ ctx.addItem({
645
+ kind: "info",
646
+ text: ctx.killed
647
+ ? `⛔ Kill switch ACTIVE${ctx.killReason ? ` — ${ctx.killReason}` : ""}. Release it with /kill off.`
648
+ : "Kill switch is off. Engage it with /kill [reason], or Ctrl+K from anywhere.",
649
+ });
650
+ return;
651
+ }
652
+ if (ctx.killed) {
653
+ ctx.addItem({
654
+ kind: "info",
655
+ text: `⛔ Kill switch is already ACTIVE${ctx.killReason ? ` — ${ctx.killReason}` : ""}. Release it with /kill off.`,
656
+ });
657
+ return;
658
+ }
659
+ ctx.engageKill(arg || undefined);
660
+ },
661
+ "/exit": (ctx) => ctx.exit(),
662
+ "/quit": (ctx) => ctx.exit(),
663
+ };
664
+ /**
665
+ * Commands that still work while the kill switch is engaged: releasing it,
666
+ * leaving, and reading back what happened. Everything else — anything that
667
+ * drives the agent, spends tokens, or touches the workspace — is refused,
668
+ * including custom commands (which are just prompts in disguise).
669
+ */
670
+ const ALLOWED_WHILE_KILLED = new Set([
671
+ "/kill",
672
+ "/help",
673
+ "/exit",
674
+ "/quit",
675
+ "/audit",
676
+ "/cost",
677
+ "/diff",
678
+ "/budget",
679
+ "/mcp",
680
+ "/checkpoint",
681
+ "/skills",
682
+ "/plugins",
683
+ ]);
684
+ /** Dispatch a slash command: built-in, then custom, then MCP prompt, then "unknown". */
685
+ export async function runCommand(cmd, ctx) {
686
+ if (ctx.killed && !ALLOWED_WHILE_KILLED.has(cmd)) {
687
+ ctx.addItem({
688
+ kind: "info",
689
+ text: `⛔ Kill switch ACTIVE${ctx.killReason ? ` — ${ctx.killReason}` : ""}. ` +
690
+ `${cmd} is blocked. Release it with /kill off.`,
691
+ });
692
+ return;
693
+ }
694
+ const handler = handlers[cmd];
695
+ if (handler)
696
+ return handler(ctx);
697
+ const custom = ctx.customCommands.find((c) => c.name === cmd);
698
+ if (custom) {
699
+ ctx.addItem({ kind: "user", text: ctx.raw.trim() });
700
+ return ctx.runAgent(expandCommand(custom.body, await ctx.expandMentions(ctx.arg)));
701
+ }
702
+ // Checked after built-ins and the user's own command files, so a server
703
+ // can't redefine /plan by naming a prompt "plan".
704
+ const prompt = mcpPrompts().find((p) => p.command === cmd);
705
+ if (prompt) {
706
+ const missing = prompt.args.filter((a) => a.required).map((a) => a.name);
707
+ if (missing.length && !ctx.arg.trim()) {
708
+ ctx.addItem({
709
+ kind: "info",
710
+ text: `${cmd} needs ${missing.join(", ")}.\n\nUsage: ${cmd} <${missing.join("> <")}>`,
711
+ });
712
+ return;
713
+ }
714
+ ctx.addItem({ kind: "user", text: ctx.raw.trim() });
715
+ ctx.setActivity(`Fetching ${prompt.name} from ${prompt.server}…`);
716
+ let expanded;
717
+ try {
718
+ expanded = await prompt.expand(ctx.arg);
719
+ }
720
+ catch (err) {
721
+ ctx.addItem({
722
+ kind: "info",
723
+ text: `${cmd} failed: ${err instanceof Error ? err.message : String(err)}`,
724
+ });
725
+ return;
726
+ }
727
+ finally {
728
+ ctx.setActivity(null);
729
+ }
730
+ if (!expanded.trim()) {
731
+ ctx.addItem({ kind: "info", text: `${cmd} returned an empty prompt.` });
732
+ return;
733
+ }
734
+ // The server wrote this text, so it's untrusted input being handed to the
735
+ // model as if the user typed it — label it rather than let it pass as ours.
736
+ return ctx.runAgent(`[MCP prompt "${prompt.name}" from server "${prompt.server}" — external content]\n\n${expanded}`);
737
+ }
738
+ ctx.addItem({ kind: "info", text: `Unknown command: ${cmd}. Try /help` });
739
+ }