takomi 2.1.43 → 2.1.45

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 (44) hide show
  1. package/.pi/README.md +4 -3
  2. package/.pi/extensions/oauth-router/README.md +3 -3
  3. package/.pi/extensions/oauth-router/commands.ts +66 -39
  4. package/.pi/extensions/oauth-router/config.ts +34 -34
  5. package/.pi/extensions/oauth-router/index.ts +51 -10
  6. package/.pi/extensions/oauth-router/report-ui.ts +205 -0
  7. package/.pi/extensions/oauth-router/scripts/vibe-verify.py +5 -5
  8. package/.pi/extensions/takomi-context-manager/diagnostics-tools.ts +41 -3
  9. package/.pi/extensions/takomi-context-manager/diagnostics.ts +26 -0
  10. package/.pi/extensions/takomi-context-manager/index.ts +20 -9
  11. package/.pi/extensions/takomi-context-manager/model-policy-gate.ts +36 -15
  12. package/.pi/extensions/takomi-context-manager/policy-tools.ts +93 -42
  13. package/.pi/extensions/takomi-context-manager/skill-categories.d.ts +11 -0
  14. package/.pi/extensions/takomi-context-manager/skill-categories.js +212 -0
  15. package/.pi/extensions/takomi-context-manager/skill-registry.ts +179 -8
  16. package/.pi/extensions/takomi-context-manager/skill-tools.ts +208 -103
  17. package/.pi/extensions/takomi-context-manager/tool-renderers.ts +112 -0
  18. package/.pi/extensions/takomi-context-manager/types.ts +6 -0
  19. package/.pi/extensions/takomi-runtime/commands.ts +45 -12
  20. package/.pi/extensions/takomi-runtime/gate-provenance.ts +24 -0
  21. package/.pi/extensions/takomi-runtime/index.ts +133 -56
  22. package/.pi/extensions/takomi-runtime/routing-policy.ts +14 -2
  23. package/.pi/extensions/takomi-runtime/takomi-stats.js +46 -26
  24. package/.pi/extensions/takomi-runtime/tool-renderers.ts +234 -0
  25. package/.pi/extensions/takomi-runtime/workflow-catalog.ts +54 -0
  26. package/.pi/extensions/takomi-subagents/agents.ts +4 -0
  27. package/.pi/extensions/takomi-subagents/async-lifecycle.ts +401 -0
  28. package/.pi/extensions/takomi-subagents/detached-results.ts +940 -0
  29. package/.pi/extensions/takomi-subagents/index.ts +46 -1
  30. package/.pi/extensions/takomi-subagents/native-render.ts +250 -188
  31. package/.pi/extensions/takomi-subagents/pi-subagents-engine.ts +55 -46
  32. package/.pi/extensions/takomi-subagents/pi-subagents-internal.ts +11 -5
  33. package/.pi/extensions/takomi-subagents/result-heartbeat.ts +55 -0
  34. package/.pi/extensions/takomi-subagents/subagent-ux.ts +162 -0
  35. package/.pi/extensions/takomi-subagents/tool-runner.ts +158 -28
  36. package/.pi/takomi/model-routing.md +282 -3
  37. package/assets/.agent/skills/shared-resend-portfolio/SKILL.md +124 -0
  38. package/package.json +4 -3
  39. package/src/pi-harness.js +41 -1
  40. package/src/pi-takomi-core/types.ts +10 -0
  41. package/src/pi-takomi-core/workflows.ts +86 -45
  42. package/src/skills-catalog.js +2 -202
  43. package/src/skills-installer.js +4 -1
  44. package/src/takomi-stats.js +5 -5
@@ -1,103 +1,208 @@
1
- import { readFile } from "node:fs/promises";
2
- import path from "node:path";
3
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
- import { Type } from "typebox";
5
- import type { ContextManagerState } from "./state";
6
- import { discoverSkillsFromFilesystem, findSkill, mergeSkills, normalizeName, sortedSkills } from "./skill-registry";
7
- import { persistReportSnapshot, restoreReportFromSession } from "./session-state";
8
-
9
- function renderSkillIndex(state: ContextManagerState): string {
10
- const skills = sortedSkills(state.skills);
11
- if (skills.length === 0) return "Available skills (names only): none discovered.";
12
- return ["Available skills (names only):", ...skills.map((skill) => `- ${skill.name}`)].join("\n");
13
- }
14
-
15
- function renderManifest(state: ContextManagerState, names: string[]): string {
16
- if (names.length === 0) return "No skills requested.";
17
- return names.map((name) => {
18
- const skill = findSkill(state.skills, name);
19
- if (!skill) {
20
- const close = sortedSkills(state.skills).filter((candidate) => normalizeName(candidate.name).includes(normalizeName(name).slice(0, 4))).slice(0, 5).map((candidate) => candidate.name);
21
- return [`Skill not found: ${name}`, close.length ? `Known close matches: ${close.join(", ")}` : ""].filter(Boolean).join("\n");
22
- }
23
- return [`Skill: ${skill.name}`, `Description: ${skill.description ?? "(no description discovered)"}`, `Location: ${skill.location ?? "(no location discovered)"}`].join("\n");
24
- }).join("\n\n");
25
- }
26
-
27
- async function loadSkillContent(location: string): Promise<string> {
28
- const fileName = path.basename(location).toLowerCase();
29
- if (fileName !== "skill.md" && !location.toLowerCase().endsWith(".md")) throw new Error(`Refusing to load non-markdown skill location: ${location}`);
30
- return readFile(location, "utf8");
31
- }
32
-
33
- async function ensureSkillsDiscovered(state: ContextManagerState, cwd: string): Promise<void> {
34
- if (state.skills.size > 0) return;
35
- state.skills = mergeSkills(await discoverSkillsFromFilesystem(cwd));
36
- state.report.skillCount = state.skills.size;
37
- }
38
-
39
- export function registerSkillTools(pi: ExtensionAPI, state: ContextManagerState): void {
40
- pi.registerTool({
41
- name: "skill_index",
42
- label: "Skill Index",
43
- description: "Return the available skill names only. Use this to inspect capability names without loading descriptions or full instructions.",
44
- promptSnippet: "List available skill names only for progressive skill loading",
45
- parameters: Type.Object({}),
46
- async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
47
- restoreReportFromSession(state, ctx);
48
- await ensureSkillsDiscovered(state, ctx.cwd);
49
- state.report.timestamp = new Date().toISOString();
50
- state.report.cwd = ctx.cwd;
51
- state.report.toolCalls.skillIndex += 1;
52
- persistReportSnapshot(pi, state, "skill_index");
53
- return { content: [{ type: "text", text: renderSkillIndex(state) }], details: { skillCount: state.skills.size } };
54
- },
55
- });
56
-
57
- pi.registerTool({
58
- name: "skill_manifest",
59
- label: "Skill Manifest",
60
- description: "Return descriptions and locations for selected skills without loading full SKILL.md instructions.",
61
- promptSnippet: "Show selected skill descriptions and locations without full instructions",
62
- parameters: Type.Object({ skills: Type.Array(Type.String({ description: "Skill name to inspect" })) }),
63
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
64
- restoreReportFromSession(state, ctx);
65
- await ensureSkillsDiscovered(state, ctx.cwd);
66
- state.report.timestamp = new Date().toISOString();
67
- state.report.cwd = ctx.cwd;
68
- state.report.toolCalls.skillManifest += 1;
69
- persistReportSnapshot(pi, state, "skill_manifest");
70
- return { content: [{ type: "text", text: renderManifest(state, params.skills) }], details: { requested: params.skills } };
71
- },
72
- });
73
-
74
- pi.registerTool({
75
- name: "skill_load",
76
- label: "Skill Load",
77
- description: "Load the full SKILL.md content for one selected skill that will actually be used.",
78
- promptSnippet: "Load full SKILL.md instructions for one selected skill",
79
- parameters: Type.Object({ skill: Type.String({ description: "Exact skill name to load" }) }),
80
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
81
- restoreReportFromSession(state, ctx);
82
- await ensureSkillsDiscovered(state, ctx.cwd);
83
- state.report.timestamp = new Date().toISOString();
84
- state.report.cwd = ctx.cwd;
85
- state.report.toolCalls.skillLoad += 1;
86
- const skill = findSkill(state.skills, params.skill);
87
- if (!skill?.location) {
88
- persistReportSnapshot(pi, state, "skill_load_not_found");
89
- return { content: [{ type: "text", text: renderManifest(state, [params.skill]) }], details: { found: false, requested: params.skill }, isError: true };
90
- }
91
- try {
92
- const content = await loadSkillContent(skill.location);
93
- state.report.loadedByTool = [...new Set([...state.report.loadedByTool, skill.name])].sort();
94
- persistReportSnapshot(pi, state, "skill_load");
95
- return { content: [{ type: "text", text: [`Skill: ${skill.name}`, `Location: ${skill.location}`, "", content].join("\n") }], details: { found: true, skill: skill.name, location: skill.location } };
96
- } catch (error) {
97
- const message = error instanceof Error ? error.message : String(error);
98
- persistReportSnapshot(pi, state, "skill_load_error");
99
- return { content: [{ type: "text", text: message }], details: { found: true, skill: skill.name, error: message }, isError: true };
100
- }
101
- },
102
- });
103
- }
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { Container, Markdown, Text } from "@earendil-works/pi-tui";
5
+ import { getMarkdownTheme, keyHint } from "@earendil-works/pi-coding-agent";
6
+ import { Type } from "typebox";
7
+ import type { ContextManagerState } from "./state";
8
+ import { discoverSkillsFromFilesystem, findSkill, mergeSkills, normalizeName, skillIndexRenderGroups, sortedSkills, type SkillIndexRenderGroup } from "./skill-registry";
9
+ import { persistReportSnapshot, restoreReportFromSession } from "./session-state";
10
+ import { renderCompactCard, renderExpandedMarkdown, renderToolCall, resultText, sanitizePresentation } from "./tool-renderers";
11
+
12
+ function renderSkillIndex(state: ContextManagerState): string {
13
+ const skills = sortedSkills(state.skills);
14
+ if (skills.length === 0) return "Available skills (names only): none discovered.";
15
+ return ["Available skills (names only):", ...skills.map((skill) => `- ${skill.name}`)].join("\n");
16
+ }
17
+
18
+ function renderManifest(state: ContextManagerState, names: string[]): string {
19
+ if (names.length === 0) return "No skills requested.";
20
+ return names.map((name) => {
21
+ const skill = findSkill(state.skills, name);
22
+ if (!skill) {
23
+ const close = sortedSkills(state.skills).filter((candidate) => normalizeName(candidate.name).includes(normalizeName(name).slice(0, 4))).slice(0, 5).map((candidate) => candidate.name);
24
+ return [`Skill not found: ${name}`, close.length ? `Known close matches: ${close.join(", ")}` : ""].filter(Boolean).join("\n");
25
+ }
26
+ return [`Skill: ${skill.name}`, `Description: ${skill.description ?? "(no description discovered)"}`, `Location: ${skill.location ?? "(no location discovered)"}`].join("\n");
27
+ }).join("\n\n");
28
+ }
29
+
30
+ const COMPACT_CATEGORY_LIMIT = 3;
31
+
32
+ function groupSummaryItems(groups: SkillIndexRenderGroup[], maximum = groups.length): string[] {
33
+ const visible = groups.slice(0, maximum).map((group) => `${group.category} ${group.skills.length}`);
34
+ const overflow = groups.length - visible.length;
35
+ return [...visible, ...(overflow > 0 ? [`+${overflow} more categories`] : [])];
36
+ }
37
+
38
+ function groupSummary(groups: SkillIndexRenderGroup[], maximum = groups.length): string {
39
+ return groupSummaryItems(groups, maximum).join(" · ");
40
+ }
41
+
42
+ function renderGroupedSkillIndex(groups: SkillIndexRenderGroup[]): string {
43
+ if (groups.length === 0) return "No skills discovered.";
44
+ return groups.map((group) => [
45
+ `## ${group.category}`,
46
+ "",
47
+ ...group.skills.map((skill) => `- \`${skill.name}\`${skill.description ? ` — ${skill.description}` : ""}`),
48
+ ].join("\n")).join("\n\n");
49
+ }
50
+
51
+ function skillMarkdown(text: string): string {
52
+ const separator = text.indexOf("\n\n");
53
+ return separator >= 0 ? text.slice(separator + 2) : text;
54
+ }
55
+
56
+ async function loadSkillContent(location: string): Promise<string> {
57
+ const fileName = path.basename(location).toLowerCase();
58
+ if (fileName !== "skill.md" && !location.toLowerCase().endsWith(".md")) throw new Error(`Refusing to load non-markdown skill location: ${location}`);
59
+ return readFile(location, "utf8");
60
+ }
61
+
62
+ async function ensureSkillsDiscovered(state: ContextManagerState, cwd: string): Promise<void> {
63
+ if (state.skills.size > 0) return;
64
+ state.skills = mergeSkills(await discoverSkillsFromFilesystem(cwd));
65
+ state.report.skillCount = state.skills.size;
66
+ }
67
+
68
+ export function registerSkillTools(pi: ExtensionAPI, state: ContextManagerState): void {
69
+ pi.registerTool({
70
+ name: "skill_index",
71
+ label: "Skill Index",
72
+ description: "Return the available skill names only. Use this to inspect capability names without loading descriptions or full instructions.",
73
+ promptSnippet: "List available skill names only for progressive skill loading",
74
+ parameters: Type.Object({}),
75
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
76
+ restoreReportFromSession(state, ctx);
77
+ await ensureSkillsDiscovered(state, ctx.cwd);
78
+ state.report.timestamp = new Date().toISOString();
79
+ state.report.cwd = ctx.cwd;
80
+ state.report.toolCalls.skillIndex += 1;
81
+ persistReportSnapshot(pi, state, "skill_index");
82
+ const groups = skillIndexRenderGroups(state.skills.values());
83
+ return { content: [{ type: "text", text: renderSkillIndex(state) }], details: { skillCount: state.skills.size, groups } };
84
+ },
85
+ renderCall(_args, theme) {
86
+ return renderToolCall("skill_index", undefined, theme);
87
+ },
88
+ renderResult(result, { expanded }, theme) {
89
+ const details = result.details as { skillCount?: number; groups?: SkillIndexRenderGroup[] } | undefined;
90
+ const groups = details?.groups ?? [];
91
+ const count = details?.skillCount ?? groups.reduce((total, group) => total + group.skills.length, 0);
92
+ if (!expanded) {
93
+ return renderCompactCard({
94
+ status: count ? "success" : "pending",
95
+ title: "Skill index",
96
+ summary: count ? `${count} skills across ${groups.length} categories` : "no skills discovered",
97
+ responsiveMetadata: groups.length ? groupSummaryItems(groups, COMPACT_CATEGORY_LIMIT) : undefined,
98
+ }, theme);
99
+ }
100
+ return renderExpandedMarkdown({
101
+ status: count ? "success" : "pending",
102
+ title: "Skill index",
103
+ summary: count ? `${count} skills across ${groups.length} categories` : "no skills discovered",
104
+ metadata: groups.length ? [groupSummary(groups)] : undefined,
105
+ markdown: renderGroupedSkillIndex(groups),
106
+ }, theme);
107
+ },
108
+ });
109
+
110
+ pi.registerTool({
111
+ name: "skill_manifest",
112
+ label: "Skill Manifest",
113
+ description: "Return descriptions and locations for selected skills without loading full SKILL.md instructions.",
114
+ promptSnippet: "Show selected skill descriptions and locations without full instructions",
115
+ parameters: Type.Object({ skills: Type.Array(Type.String({ description: "Skill name to inspect" })) }),
116
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
117
+ restoreReportFromSession(state, ctx);
118
+ await ensureSkillsDiscovered(state, ctx.cwd);
119
+ state.report.timestamp = new Date().toISOString();
120
+ state.report.cwd = ctx.cwd;
121
+ state.report.toolCalls.skillManifest += 1;
122
+ persistReportSnapshot(pi, state, "skill_manifest");
123
+ const found = params.skills.filter((name) => Boolean(findSkill(state.skills, name)));
124
+ const missing = params.skills.filter((name) => !findSkill(state.skills, name));
125
+ return { content: [{ type: "text", text: renderManifest(state, params.skills) }], details: { requested: params.skills, found, missing } };
126
+ },
127
+ renderCall(args, theme) {
128
+ return renderToolCall("skill_manifest", `${args.skills.length} requested`, theme);
129
+ },
130
+ renderResult(result, { expanded }, theme) {
131
+ const details = result.details as { requested?: string[]; found?: string[]; missing?: string[] } | undefined;
132
+ const requested = details?.requested ?? [];
133
+ const found = details?.found ?? [];
134
+ const missing = details?.missing ?? [];
135
+ const status = missing.length ? "warning" : requested.length ? "success" : "pending";
136
+ const summary = missing.length ? `${found.length} found · ${missing.length} unavailable` : requested.length ? `${found.length} skills available` : "no skills requested";
137
+ const metadata = missing.length ? `Missing: ${missing.join(", ")}` : `${requested.length} requested`;
138
+ if (!expanded) return renderCompactCard({ status, title: "Skill manifest", summary, metadata }, theme);
139
+ return renderExpandedMarkdown({ status, title: "Skill manifest", summary, metadata: [metadata], markdown: resultText(result) }, theme);
140
+ },
141
+ });
142
+
143
+ pi.registerTool({
144
+ name: "skill_load",
145
+ label: "Skill Load",
146
+ description: "Load the full SKILL.md content for one selected skill that will actually be used.",
147
+ promptSnippet: "Load full SKILL.md instructions for one selected skill",
148
+ parameters: Type.Object({ skill: Type.String({ description: "Exact skill name to load" }) }),
149
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
150
+ restoreReportFromSession(state, ctx);
151
+ await ensureSkillsDiscovered(state, ctx.cwd);
152
+ state.report.timestamp = new Date().toISOString();
153
+ state.report.cwd = ctx.cwd;
154
+ state.report.toolCalls.skillLoad += 1;
155
+ const skill = findSkill(state.skills, params.skill);
156
+ if (!skill?.location) {
157
+ persistReportSnapshot(pi, state, "skill_load_not_found");
158
+ return { content: [{ type: "text", text: renderManifest(state, [params.skill]) }], details: { found: false, requested: params.skill }, isError: true };
159
+ }
160
+ try {
161
+ const content = await loadSkillContent(skill.location);
162
+ state.report.loadedByTool = [...new Set([...state.report.loadedByTool, skill.name])].sort();
163
+ persistReportSnapshot(pi, state, "skill_load");
164
+ return {
165
+ content: [{ type: "text", text: [`Skill: ${skill.name}`, `Location: ${skill.location}`, "", content].join("\n") }],
166
+ details: {
167
+ found: true,
168
+ skill: skill.name,
169
+ description: skill.description,
170
+ location: skill.location,
171
+ lineCount: content.split(/\r?\n/).length,
172
+ },
173
+ };
174
+ } catch (error) {
175
+ const message = error instanceof Error ? error.message : String(error);
176
+ persistReportSnapshot(pi, state, "skill_load_error");
177
+ return { content: [{ type: "text", text: message }], details: { found: true, skill: skill.name, error: message }, isError: true };
178
+ }
179
+ },
180
+ renderCall(args, theme) {
181
+ return renderToolCall("skill_load", args.skill, theme);
182
+ },
183
+ renderResult(result, { expanded }, theme) {
184
+ const details = result.details as { found?: boolean; skill?: string; description?: string; location?: string; lineCount?: number; error?: string } | undefined;
185
+ const text = resultText(result);
186
+ if (details?.error || details?.found === false) {
187
+ const summary = details?.error ?? "skill not found";
188
+ if (!expanded) return renderCompactCard({ status: "error", title: "Skill load", summary }, theme);
189
+ return renderExpandedMarkdown({ status: "error", title: "Skill load", summary, markdown: text }, theme);
190
+ }
191
+
192
+ const name = sanitizePresentation(details?.skill ?? "skill");
193
+ const description = details?.description ? sanitizePresentation(details.description) : undefined;
194
+ const summary = description ?? "skill instructions loaded";
195
+ const metadata = `${details?.lineCount ?? text.split(/\r?\n/).length} lines`;
196
+ if (!expanded) return renderCompactCard({ status: "success", title: name, summary, metadata }, theme);
197
+
198
+ const location = details?.location ? sanitizePresentation(details.location) : undefined;
199
+ const container = new Container();
200
+ container.addChild(new Text(`${theme.fg("success", "✓")} ${theme.fg("accent", theme.bold(name))} ${theme.fg("muted", "skill instructions")}`, 0, 0));
201
+ if (description) container.addChild(new Text(theme.fg("muted", description), 0, 0));
202
+ if (location) container.addChild(new Text(theme.fg("dim", location), 0, 0));
203
+ container.addChild(new Text(theme.fg("dim", keyHint("app.tools.expand", "collapse")), 0, 0));
204
+ container.addChild(new Markdown(sanitizePresentation(skillMarkdown(text)), 0, 1, getMarkdownTheme()));
205
+ return container;
206
+ },
207
+ });
208
+ }
@@ -0,0 +1,112 @@
1
+ import { getMarkdownTheme, keyHint } from "@earendil-works/pi-coding-agent";
2
+ import { Container, Markdown, Text, type Component } from "@earendil-works/pi-tui";
3
+
4
+ type Theme = {
5
+ fg(color: string, text: string): string;
6
+ bold(text: string): string;
7
+ };
8
+
9
+ type ToolResult = {
10
+ content?: Array<{ type?: string; text?: string }>;
11
+ };
12
+
13
+ const OSC_SEQUENCE = /\x1B\][\s\S]*?(?:\x07|\x1B\\|$)/g;
14
+ const STRING_TERMINATED_SEQUENCE = /\x1B[PX^_][\s\S]*?(?:\x1B\\|$)/g;
15
+ const CSI_SEQUENCE = /(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~]/g;
16
+ const ESC_SEQUENCE = /\x1B(?:[()][0-2A-Z0-9]|[=>]|[ -/]*[@-~]?)/g;
17
+ const UNSAFE_CONTROLS = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g;
18
+
19
+ /**
20
+ * Remove terminal controls at the TUI boundary while retaining Markdown's
21
+ * printable syntax and line structure. Tool result content remains unchanged.
22
+ */
23
+ export function sanitizePresentation(value: string): string {
24
+ return value
25
+ .replace(OSC_SEQUENCE, "")
26
+ .replace(STRING_TERMINATED_SEQUENCE, "")
27
+ .replace(CSI_SEQUENCE, "")
28
+ .replace(ESC_SEQUENCE, "")
29
+ .replace(UNSAFE_CONTROLS, "");
30
+ }
31
+
32
+ /** Read model-facing content without modifying it, then make a presentation-safe copy. */
33
+ export function resultText(result: ToolResult): string {
34
+ const text = result.content?.filter((part) => part.type === "text").map((part) => part.text ?? "").join("\n") ?? "";
35
+ return sanitizePresentation(text);
36
+ }
37
+
38
+ export function renderToolCall(toolName: string, target: string | undefined, theme: Theme): Text {
39
+ const safeToolName = sanitizePresentation(toolName);
40
+ const safeTarget = target ? sanitizePresentation(target) : "";
41
+ return new Text(
42
+ `${theme.fg("toolTitle", theme.bold(`${safeToolName} `))}${safeTarget ? theme.fg("accent", safeTarget) : ""}`,
43
+ 0,
44
+ 0,
45
+ );
46
+ }
47
+
48
+ export function renderCompactCard(options: {
49
+ status: "success" | "warning" | "error" | "pending";
50
+ title: string;
51
+ summary: string;
52
+ metadata?: string;
53
+ /** Items render inline on wide terminals and stack below 80 columns. */
54
+ responsiveMetadata?: string[];
55
+ }, theme: Theme): Component {
56
+ const status = {
57
+ success: ["✓", "success"],
58
+ warning: ["⚠", "warning"],
59
+ error: ["✗", "error"],
60
+ pending: ["…", "muted"],
61
+ } as const;
62
+ const [icon, color] = status[options.status];
63
+ const title = sanitizePresentation(options.title);
64
+ const summary = sanitizePresentation(options.summary);
65
+ const metadata = options.metadata ? sanitizePresentation(options.metadata) : undefined;
66
+ const responsiveMetadata = options.responsiveMetadata?.map(sanitizePresentation).filter(Boolean);
67
+ const header = `${theme.fg(color, icon)} ${theme.fg("accent", theme.bold(title))} ${theme.fg("muted", summary)}`;
68
+ const hint = keyHint("app.tools.expand", "view details");
69
+
70
+ if (!responsiveMetadata?.length) {
71
+ const detail = metadata ? `${metadata} · ${hint}` : hint;
72
+ return new Text([header, theme.fg("dim", detail)].join("\n"), 0, 0);
73
+ }
74
+
75
+ return {
76
+ invalidate() {},
77
+ render(width: number): string[] {
78
+ const details = width < 80
79
+ ? [...responsiveMetadata.map((item) => theme.fg("dim", item)), theme.fg("dim", hint)]
80
+ : [theme.fg("dim", `${responsiveMetadata.join(" · ")} · ${hint}`)];
81
+ return new Text([header, ...details].join("\n"), 0, 0).render(width);
82
+ },
83
+ };
84
+ }
85
+
86
+ export function renderExpandedMarkdown(options: {
87
+ status: "success" | "warning" | "error" | "pending";
88
+ title: string;
89
+ summary: string;
90
+ metadata?: string[];
91
+ markdown: string;
92
+ }, theme: Theme): Container {
93
+ const status = {
94
+ success: ["✓", "success"],
95
+ warning: ["⚠", "warning"],
96
+ error: ["✗", "error"],
97
+ pending: ["…", "muted"],
98
+ } as const;
99
+ const [icon, color] = status[options.status];
100
+ const title = sanitizePresentation(options.title);
101
+ const summary = sanitizePresentation(options.summary);
102
+ const container = new Container();
103
+ container.addChild(new Text(
104
+ `${theme.fg(color, icon)} ${theme.fg("accent", theme.bold(title))} ${theme.fg("muted", summary)}`,
105
+ 0,
106
+ 0,
107
+ ));
108
+ for (const detail of options.metadata ?? []) container.addChild(new Text(theme.fg("dim", sanitizePresentation(detail)), 0, 0));
109
+ container.addChild(new Text(theme.fg("dim", keyHint("app.tools.expand", "collapse")), 0, 0));
110
+ container.addChild(new Markdown(sanitizePresentation(options.markdown), 0, 1, getMarkdownTheme()));
111
+ return container;
112
+ }
@@ -2,6 +2,12 @@ export type SkillRecord = {
2
2
  name: string;
3
3
  description?: string;
4
4
  location?: string;
5
+ /** Explicit skill metadata, preferred over all other category sources. */
6
+ category?: string;
7
+ /** Category supplied by an installer/source registry for this exact skill path. */
8
+ sourceCategory?: string;
9
+ /** Package/source identifier used only when no explicit, registry, or path category exists. */
10
+ packageName?: string;
5
11
  source: "systemPromptOptions" | "xml" | "filesystem" | "tool";
6
12
  };
7
13
 
@@ -28,6 +28,7 @@ export type TakomiRuntimeCommandState = {
28
28
  type RegisterTakomiCommandOptions = {
29
29
  getState(): TakomiRuntimeCommandState;
30
30
  updateState(ctx: ExtensionContext, mutator: () => void, message?: string | (() => string)): Promise<void>;
31
+ recordUserGateAutoProvenance(authorized: boolean): void;
31
32
  resetRuntime(ctx: ExtensionCommandContext): Promise<void>;
32
33
  setStageAndWorkflow(stage: VibeLifecycleStage, options?: { preserveRole?: boolean }): void;
33
34
  createPlanSession(ctx: ExtensionCommandContext, title?: string): Promise<string>;
@@ -36,6 +37,13 @@ type RegisterTakomiCommandOptions = {
36
37
  };
37
38
 
38
39
  export function registerTakomiCommands(pi: ExtensionAPI, options: RegisterTakomiCommandOptions): void {
40
+ // The custom runtime widget is intentionally absent in idle/direct mode, so
41
+ // suppress routine notifications only while the changed state remains visible.
42
+ function hasVisibleRuntimeWidget(): boolean {
43
+ const state = options.getState();
44
+ return state.enabled && (state.modeSource ?? "idle") !== "idle";
45
+ }
46
+
39
47
  async function handleStage(ctx: ExtensionCommandContext, stage: VibeLifecycleStage, prompt?: string): Promise<void> {
40
48
  await options.updateState(ctx, () => {
41
49
  options.getState().enabled = true;
@@ -75,26 +83,30 @@ export function registerTakomiCommands(pi: ExtensionAPI, options: RegisterTakomi
75
83
  state.autoOrch = false;
76
84
  state.planMode = true;
77
85
  state.launchMode = "manual";
86
+ options.recordUserGateAutoProvenance(false);
78
87
  state.role = "review";
79
88
  state.stage = undefined;
80
89
  state.workflow = undefined;
81
90
  state.modeSource = "manual";
82
91
  state.modeReason = "/takomi mode review";
83
92
  }
84
- }, () => `Takomi mode set to ${mode}`);
93
+ }, () => hasVisibleRuntimeWidget() ? "" : `Takomi mode set to ${mode}`);
85
94
  }
86
95
 
87
96
  async function handleGate(ctx: ExtensionCommandContext, gate?: string): Promise<void> {
88
- if (gate !== "auto" && gate !== "review") {
89
- ctx.ui.notify("Usage: /takomi gate <auto|review>", "warning");
97
+ if (gate !== "auto" && gate !== "review" && gate !== "manual") {
98
+ ctx.ui.notify("Usage: /takomi gate <auto|review|manual>", "warning");
90
99
  return;
91
100
  }
101
+ const auto = gate === "auto";
92
102
  await options.updateState(ctx, () => {
93
103
  const state = options.getState();
94
104
  state.enabled = true;
95
- state.launchMode = gate === "review" ? "manual" : "auto";
96
- state.autoOrch = gate === "auto";
97
- }, () => `Takomi execution gate set to ${gate}`);
105
+ state.launchMode = auto ? "auto" : "manual";
106
+ state.autoOrch = auto;
107
+ // This dedicated entry is the only user-gate authorization signal.
108
+ options.recordUserGateAutoProvenance(auto);
109
+ }, () => hasVisibleRuntimeWidget() ? "" : `Takomi execution gate set to ${auto ? "auto" : "review"}`);
98
110
  }
99
111
 
100
112
  async function handleRouting(ctx: ExtensionCommandContext, body?: string): Promise<void> {
@@ -173,20 +185,41 @@ export function registerTakomiCommands(pi: ExtensionAPI, options: RegisterTakomi
173
185
  const policyText = scopeMatch?.[2] ?? trimmed.replace(/^set\s+/i, "");
174
186
 
175
187
  try {
176
- const preview = previewTakomiRoutingPolicy(ctx.cwd, policyText, { scope });
188
+ const availableModels = (() => {
189
+ try {
190
+ const available = (ctx as ExtensionCommandContext & { modelRegistry?: { getAvailable?: () => Array<{ provider?: string; id?: string; name?: string }> } }).modelRegistry?.getAvailable?.() ?? [];
191
+ return available.map((model) => `${model.provider ? `${model.provider}/` : ""}${model.id ?? model.name ?? ""}`).filter(Boolean);
192
+ } catch {
193
+ return [];
194
+ }
195
+ })();
196
+ const [preview, activePolicy] = await Promise.all([
197
+ Promise.resolve(previewTakomiRoutingPolicy(ctx.cwd, policyText, { scope, availableModels })),
198
+ resolveTakomiRoutingPolicy(ctx.cwd),
199
+ ]);
200
+ const replacementWarning = activePolicy.text && activePolicy.text.trim().length > preview.policy.trim().length * 2
201
+ ? `WARNING: This input is much shorter than the active policy (${preview.policy.length} vs ${activePolicy.text.length} characters). It will replace the file, not merge into it. If the user referred to a full source file or prior policy, inspect and use that complete text instead.`
202
+ : "This input replaces the selected policy file exactly; it is not merged with the active policy.";
177
203
  const reviewPrompt = [
178
204
  "Review this Takomi routing policy extraction before it is saved.",
179
205
  "",
180
206
  "Rules:",
181
207
  "- Do not invent providers or model IDs not grounded in the policy.",
182
- "- Providerless names like GPT-5.5 are routing intent unless a preferred provider/router is declared.",
183
- "- Valid Takomi roles are: general, orchestrator, architect, designer, coder, reviewer.",
184
- "- If the extraction is correct and safe, call takomi_apply_routing_policy with the exact policyText and scope below.",
185
- "- If it is ambiguous or wrong, explain what the user should clarify and do not call the tool.",
208
+ "- Providerless names are valid routing intent. Require a provider only when writing an executable model override.",
209
+ "- Check the available Pi model registry below before asking the user whether a named model exists or what its exact ID is.",
210
+ "- Conditional task-shape routes do not need to be forced into role-wide defaults or agentOverrides.",
211
+ "- Valid role-wide Takomi overrides are: general, orchestrator, architect, designer, coder, reviewer. Other headings may still be policy concepts or execution routes.",
212
+ "- Preserve the user's full authored policy. If this looks like a summary/excerpt and a richer referenced source exists, inspect that source and apply the complete intended text instead of overwriting it with the excerpt.",
213
+ "- If the extraction is correct and safe, call takomi_apply_routing_policy with the complete intended policyText and scope below.",
214
+ "- Ask only for unresolved provider/account choices or genuine policy ambiguity; do not ask for facts available from the registry or files.",
186
215
  "",
187
216
  "Deterministic extraction:",
188
217
  renderRoutingPolicyPreview(preview),
189
218
  "",
219
+ replacementWarning,
220
+ "",
221
+ availableModels.length ? `Available Pi models:\n${availableModels.map((model) => `- ${model}`).join("\n")}` : "Available Pi models: registry unavailable; inspect it before asking the user if possible.",
222
+ "",
190
223
  "Original policy text:",
191
224
  "```",
192
225
  preview.policy,
@@ -217,7 +250,7 @@ export function registerTakomiCommands(pi: ExtensionAPI, options: RegisterTakomi
217
250
  if (action === "on" || action === "off") {
218
251
  await options.updateState(ctx, () => {
219
252
  options.getState().subagentsEnabled = action === "on";
220
- }, `Takomi subagents ${action}`);
253
+ }, () => hasVisibleRuntimeWidget() ? "" : `Takomi subagents ${action}`);
221
254
  return;
222
255
  }
223
256
  if (action === "status" || !action) {
@@ -0,0 +1,24 @@
1
+ export const USER_GATE_AUTO_PROVENANCE_ENTRY = "takomi-user-gate-auto-provenance";
2
+
3
+ type SessionEntry = {
4
+ type?: unknown;
5
+ customType?: unknown;
6
+ data?: unknown;
7
+ };
8
+
9
+ function isAuthorizedData(value: unknown): value is { authorized: true } {
10
+ return typeof value === "object" && value !== null && (value as { authorized?: unknown }).authorized === true;
11
+ }
12
+
13
+ /**
14
+ * Reads the latest explicit user gate decision from session history.
15
+ * This deliberately ignores generic runtime state and profile defaults.
16
+ */
17
+ export function hasUserGateAutoProvenance(entries: readonly SessionEntry[]): boolean {
18
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
19
+ const entry = entries[index];
20
+ if (entry?.type !== "custom" || entry.customType !== USER_GATE_AUTO_PROVENANCE_ENTRY) continue;
21
+ return isAuthorizedData(entry.data);
22
+ }
23
+ return false;
24
+ }